diff --git a/CHANGELOG.md b/CHANGELOG.md index daa78481..34098df2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,16 @@ ### 新增 +- Windows 安装包写入预构建 openXYOS 运行包(`$INSTDIR\openxyos` + `openxyos-runtime.zip`),首次启动再落到 `%LOCALAPPDATA%\FreeOS\openxyos` / `~/.freeos/openxyos`,自动拉起 `http://127.0.0.1:3780`,组织页不必再点「启动边车」。 +- 项目创建用系统文件夹选择器(Wails 原生对话框,任意盘符);项目/任务/对话区默认展示实时对话列表,并提供建群/群聊入口。 - Organization OS 首页双循环:FreeOS 数据面与 openXYOS 控制面的实时状态、一键装配员工 / 打包回流 / 运行 org loop,以及边车离线时的启动恢复。 - FreeOS 官网源码:`website/`(Vite + React 静态站,正式域名 https://freeos.cnxy.tech)。视觉与章节节奏对齐 XYAI Labs(天蓝→亮光、毛玻璃、01/02/…、底座 / 系统 / 生态),文案仍是 FreeOS 产品自己的话;圆形标志 + XYAI 机器人六姿态,无 Octop 红或章鱼形象。构建产物可整包上传到 `/www/wwwroot/freeos.cnxy.tech`。 ### 变更 +- 侧栏「组织 OS」更名为「组织」(英文 Organization)。 + + - 桌面端(`OCTOP_DESKTOP` / `FREEOS_DESKTOP`)首次打开不再经过验证启动密码、选择数据库、创建管理员三步:自动绑定本地 SQLite 并使用已有 guest / local-session。首屏只保留配置 LLM API Key,可「跳过,进入工作台」,之后仍可在设置里配模型。自托管服务端向导不变。 - 侧栏「设置」分组改名为「能力」/ Capabilities。原「控制」与「管理」子模块并入顶层「系统设置」/ System Settings(页内分节;工作台 / 远程桌面仍走全屏路由)。旧 `/admin/*`、`/acp`、`/agent-config` 地址会重定向。 - 用户可见吉祥物从章鱼换成 XYAI 白色机器人姿势包(欢迎 / 空态 / 探头 / 思考 / 打字 / 任务);桌面启动页与 openXYOS 首页使用同一角色,不再加载章鱼 WebM。 diff --git a/dashboard/src/layouts/Sidebar.tsx b/dashboard/src/layouts/Sidebar.tsx index f69d924d..037209f5 100644 --- a/dashboard/src/layouts/Sidebar.tsx +++ b/dashboard/src/layouts/Sidebar.tsx @@ -13,6 +13,7 @@ import { MessageSquarePlus, FolderPlus, ListPlus, + Users, } from "lucide-react"; import { useLayoutMode } from "../context/LayoutModeContext"; import { useUserRole } from "../hooks/useUserRole"; @@ -586,6 +587,15 @@ export default function Sidebar({ {!isRailCollapsed && {t("nav.newProject")}} + ); diff --git a/dashboard/src/layouts/sidebarNav.test.ts b/dashboard/src/layouts/sidebarNav.test.ts index d611364f..4424399c 100644 --- a/dashboard/src/layouts/sidebarNav.test.ts +++ b/dashboard/src/layouts/sidebarNav.test.ts @@ -5,6 +5,8 @@ import { isGroupedNavKey, } from "./sidebarNav"; import type { OctopUser } from "../api/modules/auth"; +import en from "../locales/en.json"; +import zh from "../locales/zh.json"; const adminUser = { id: 1, @@ -55,4 +57,9 @@ describe("sidebarNav", () => { expect(keys).toContain("chat"); expect(keys).toContain("experts"); }); + + it("labels organization without the OS suffix", () => { + expect(zh.nav.organization).toBe("组织"); + expect(en.nav.organization).toBe("Organization"); + }); }); diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index fa8273ae..b4cbde42 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -7,9 +7,9 @@ "expandPanel": "Expand panel" }, "organization": { - "title": "Organization OS", + "title": "Organization", "subtitle": "A dual loop: FreeOS does the work, openXYOS governs the org — they grow each other.", - "heroTitle": "Organization OS", + "heroTitle": "Organization", "heroStory": "FreeOS does the work. openXYOS owns organization and governance. They grow each other.", "toggleLabel": "Enable organization module", "on": "Enabled", @@ -52,7 +52,7 @@ "timelineSkills": "Skills", "timelineRemote": "Pushed to control plane", "timelineGovernance": "High-risk tool blocked by governance", - "emptyTitle": "First look at Organization OS", + "emptyTitle": "First look at Organization", "emptyBody": "FreeOS on the left owns employees, skills, MCP, and tasks. openXYOS on the right owns blueprints, modules, and approvals. Start the sidecar, or run the loop once to see a real cycle.", "startSidecarTitle": "openXYOS sidecar is offline", "startSidecarBody": "Live approvals and remote apply need the sidecar. Start it here; if no launcher is bundled, use the command beside the button.", @@ -101,13 +101,25 @@ "tabProjects": "Projects", "tabTasks": "Tasks", "tabConversations": "Conversations", - "conversationsEmpty": "No project conversations yet. Start one from a project or chat.", + "conversationsEmpty": "No conversations yet. Start one or create a group above.", "empty": "No projects yet. Create one to group conversations and tasks.", "name": "Project name", "nameRequired": "Enter a project name", "workDir": "Work folder", - "workDirHint": "Optional local folder for this project", + "workDirHint": "Pick any folder on any drive with the system dialog", + "browseWorkDir": "Browse…", + "pickFolderFailed": "Could not open the folder picker", "noWorkDir": "No work folder", + "createGroup": "New group", + "groupTitle": "Group name", + "groupTitlePlaceholder": "Optional, e.g. Weekly sync", + "groupMembers": "Choose experts", + "groupMembersRequired": "Select at least two experts", + "groupCreated": "Group chat created", + "groupCreateFailed": "Could not create the group chat", + "kindDirect": "Direct", + "kindGroup": "Group", + "liveEmpty": "No conversations yet. New chats and groups show up here.", "counts": "{{conversations}} conversations · {{tasks}} tasks", "created": "Project created", "createFailed": "Could not create the project", @@ -283,10 +295,12 @@ "newConversation": "New conversation", "newTask": "New task", "newProject": "New project", + "newGroup": "New group", + "groupChat": "Group chat", "common": "Common", "control": "Control", "experts": "Experts", - "organization": "Organization OS", + "organization": "Organization", "tasks": "Tasks", "connectors": "Connectors", "acp": "ACP", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index a72cfeb1..60e0bf51 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -7,9 +7,9 @@ "expandPanel": "展开面板" }, "organization": { - "title": "组织 OS", + "title": "组织", "subtitle": "FreeOS 数据面与 openXYOS 控制面的双循环:干活与治理互相生长。", - "heroTitle": "组织 OS", + "heroTitle": "组织", "heroStory": "FreeOS 负责干活,openXYOS 负责组织与治理,两者互相生长。", "toggleLabel": "启用组织模块", "on": "已启用", @@ -52,7 +52,7 @@ "timelineSkills": "技能数", "timelineRemote": "已推送到控制面", "timelineGovernance": "高风险工具被治理拦截", - "emptyTitle": "第一次打开组织 OS", + "emptyTitle": "第一次打开组织", "emptyBody": "左侧 FreeOS 负责员工、技能、MCP 与任务;右侧 openXYOS 负责蓝图、模块与审批。先启动边车,或直接运行组织循环看一次真实闭环。", "startSidecarTitle": "openXYOS 边车未运行", "startSidecarBody": "控制面离线时无法做 live 审批与远程回流。可一键启动本机边车;若启动器不在当前环境,请按旁边的命令手动启动。", @@ -101,13 +101,25 @@ "tabProjects": "项目", "tabTasks": "任务", "tabConversations": "对话", - "conversationsEmpty": "还没有项目对话。可以从项目或对话页新建。", + "conversationsEmpty": "还没有对话。在上方新建对话或建群。", "empty": "还没有项目。新建一个来组织对话和任务。", "name": "项目名称", "nameRequired": "请输入项目名称", "workDir": "工作目录", - "workDirHint": "可选,为本项目指定本地文件夹", + "workDirHint": "通过系统文件夹选择器指定任意盘符下的目录", + "browseWorkDir": "浏览…", + "pickFolderFailed": "无法打开文件夹选择器", "noWorkDir": "未指定工作目录", + "createGroup": "建群", + "groupTitle": "群聊名称", + "groupTitlePlaceholder": "可选,例如「产品周会」", + "groupMembers": "选择专家", + "groupMembersRequired": "请至少选择两位专家", + "groupCreated": "群聊已创建", + "groupCreateFailed": "无法创建群聊", + "kindDirect": "单聊", + "kindGroup": "群聊", + "liveEmpty": "还没有对话。新建对话或建群后会出现在这里。", "counts": "{{conversations}} 个对话 · {{tasks}} 个任务", "created": "已创建项目", "createFailed": "无法创建项目", @@ -283,10 +295,12 @@ "newConversation": "新建对话", "newTask": "新建任务", "newProject": "新建项目", + "newGroup": "建群", + "groupChat": "群聊", "common": "常用", "control": "控制", "experts": "专家", - "organization": "组织 OS", + "organization": "组织", "tasks": "任务", "connectors": "连接器", "acp": "ACP", diff --git a/dashboard/src/pages/Projects/index.tsx b/dashboard/src/pages/Projects/index.tsx index 06b83e18..a347b01f 100644 --- a/dashboard/src/pages/Projects/index.tsx +++ b/dashboard/src/pages/Projects/index.tsx @@ -1,6 +1,7 @@ -import { lazy, Suspense, useEffect, useMemo, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react"; import { Button, + Checkbox, Empty, Form, Input, @@ -8,37 +9,80 @@ import { Modal, Segmented, Space, + Tag, } from "antd"; -import { FolderKanban, MessageSquareText, Plus } from "lucide-react"; +import { + FolderKanban, + FolderPlus, + ListPlus, + MessageSquarePlus, + MessageSquareText, + Users, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { useNavigate, useSearchParams } from "react-router-dom"; import PageShell from "../../layouts/PageShell"; import PageLoading from "../../components/PageLoading"; import { projectsApi, type Project } from "../../api/modules/projects"; +import { octopThreadsApi, type OctopThread } from "../../api/modules/octopThreads"; +import { useAgent, selectEnabledExperts } from "../../context/AgentContext"; +import { onSessionEvent } from "../Chat/hooks/chatStore"; +import { expertMentionToken } from "../Chat/utils/expertMention"; +import { + canPickDesktopFolder, + pickDesktopFolder, +} from "../../utils/desktopFolder"; +import { + groupChatTitle, + loadGroupChats, + saveGroupChat, + type GroupChatRecord, +} from "../../utils/groupChats"; import { message } from "../../utils/antdMessage"; const CronJobsPage = lazy(() => import("../Control/CronJobs")); -type WorkspaceView = "projects" | "tasks" | "conversations"; +type WorkspaceView = "conversations" | "projects" | "tasks"; + +interface LiveConversation { + id: string; + title: string; + kind: "direct" | "group"; + agentId: string; + lastActive: number; + subtitle?: string; +} export default function ProjectsPage() { const { t } = useTranslation(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); + const { agents, activeAgentId, setActiveAgent } = useAgent(); const [projects, setProjects] = useState([]); + const [threads, setThreads] = useState< + Array + >([]); + const [groups, setGroups] = useState(() => loadGroupChats()); const [loading, setLoading] = useState(true); const [open, setOpen] = useState(false); + const [groupOpen, setGroupOpen] = useState(false); + const [pickingFolder, setPickingFolder] = useState(false); const [form] = Form.useForm<{ name: string; work_dir?: string }>(); - const view = (searchParams.get("view") || "projects") as WorkspaceView; + const [groupForm] = Form.useForm<{ title?: string; members: string[] }>(); + const view = (searchParams.get("view") || "conversations") as WorkspaceView; + const enabledExperts = useMemo( + () => selectEnabledExperts(agents, activeAgentId, { pinActive: false }), + [agents, activeAgentId], + ); const setView = (next: WorkspaceView) => { const params = new URLSearchParams(searchParams); - if (next === "projects") params.delete("view"); + if (next === "conversations") params.delete("view"); else params.set("view", next); setSearchParams(params, { replace: true }); }; - const load = async () => { + const loadProjects = async () => { setLoading(true); try { const next = await projectsApi.list(); @@ -52,10 +96,50 @@ export default function ProjectsPage() { } }; + const loadThreads = useCallback(async () => { + if (enabledExperts.length === 0) { + setThreads([]); + return; + } + const rows = await Promise.all( + enabledExperts.map(async (agent) => { + try { + const list = await octopThreadsApi.list(agent.agent_id, 40); + return list.map((item) => ({ + ...item, + agentId: agent.agent_id, + agentName: agent.name, + })); + } catch { + return []; + } + }), + ); + setThreads(rows.flat()); + setGroups(loadGroupChats()); + }, [enabledExperts]); + useEffect(() => { - void load(); + void loadProjects(); }, []); + useEffect(() => { + void loadThreads(); + }, [loadThreads]); + + useEffect(() => { + const unsub = onSessionEvent(() => { + void loadThreads(); + }); + const id = window.setInterval(() => { + void loadThreads(); + }, 4000); + return () => { + unsub(); + window.clearInterval(id); + }; + }, [loadThreads]); + useEffect(() => { if (searchParams.get("new") === "1") { setOpen(true); @@ -63,18 +147,68 @@ export default function ProjectsPage() { next.delete("new"); setSearchParams(next, { replace: true }); } + if (searchParams.get("group") === "1") { + setGroupOpen(true); + const next = new URLSearchParams(searchParams); + next.delete("group"); + setSearchParams(next, { replace: true }); + } }, [searchParams, setSearchParams]); - const conversations = useMemo( - () => - projects.flatMap((project) => - ( - project.conversations || - project.conversation_ids.map((id) => ({ id, title: id })) - ).map((item) => ({ ...item, projectName: project.name })), - ), - [projects], - ); + const conversations = useMemo(() => { + const groupByThread = new Map(groups.map((item) => [item.threadId, item])); + const live: LiveConversation[] = threads.map((item) => { + const group = groupByThread.get(item.thread_id); + if (group) { + return { + id: item.thread_id, + title: group.title || item.title || t("nav.groupChat"), + kind: "group", + agentId: group.hostAgentId, + lastActive: Math.max(item.last_active || 0, group.lastActive), + subtitle: t("projects.kindGroup"), + }; + } + return { + id: item.thread_id, + title: item.title || t("nav.newConversation"), + kind: "direct", + agentId: item.agentId, + lastActive: item.last_active || 0, + subtitle: item.agentName, + }; + }); + for (const group of groups) { + if (live.some((row) => row.id === group.threadId)) continue; + live.push({ + id: group.threadId, + title: group.title, + kind: "group", + agentId: group.hostAgentId, + lastActive: group.lastActive, + subtitle: t("projects.kindGroup"), + }); + } + return live.sort((a, b) => b.lastActive - a.lastActive); + }, [groups, t, threads]); + + const browseWorkDir = async () => { + setPickingFolder(true); + try { + const path = await pickDesktopFolder(); + if (path) { + form.setFieldValue("work_dir", path); + } else if (!canPickDesktopFolder()) { + message.info(t("projects.pickFolderFailed")); + } + } catch (err) { + message.error( + err instanceof Error ? err.message : t("projects.pickFolderFailed"), + ); + } finally { + setPickingFolder(false); + } + }; const create = async () => { const values = await form.validateFields(); @@ -86,7 +220,7 @@ export default function ProjectsPage() { message.success(t("projects.created")); setOpen(false); form.resetFields(); - await load(); + await loadProjects(); } catch (err) { message.error( err instanceof Error ? err.message : t("projects.createFailed"), @@ -94,39 +228,107 @@ export default function ProjectsPage() { } }; + const openConversation = (item: LiveConversation) => { + setActiveAgent(item.agentId); + navigate(`/chat/${item.agentId}/${item.id}`); + }; + + const startNewConversation = () => { + const agentId = activeAgentId || enabledExperts[0]?.agent_id; + if (!agentId) { + navigate("/chat"); + return; + } + setActiveAgent(agentId); + navigate(`/chat/${agentId}`, { state: { newChat: true } }); + }; + + const createGroup = async () => { + const values = await groupForm.validateFields(); + const members = values.members || []; + if (members.length < 2) { + message.error(t("projects.groupMembersRequired")); + return; + } + const hostId = members[0]; + const named = members + .map((id) => enabledExperts.find((agent) => agent.agent_id === id)?.name) + .filter((name): name is string => Boolean(name)); + try { + const created = await octopThreadsApi.create(hostId); + const title = groupChatTitle(values.title, named); + await octopThreadsApi.rename(hostId, created.thread_id, title); + const record: GroupChatRecord = { + id: created.thread_id, + threadId: created.thread_id, + hostAgentId: hostId, + memberIds: members, + title, + createdAt: Date.now(), + lastActive: Date.now(), + }; + setGroups(saveGroupChat(record)); + const prefill = named.map((name) => expertMentionToken(name)).join(" "); + message.success(t("projects.groupCreated")); + setGroupOpen(false); + groupForm.resetFields(); + setActiveAgent(hostId); + navigate(`/chat/${hostId}/${created.thread_id}`, { + state: { prefillInput: `${prefill} ` }, + }); + } catch (err) { + message.error( + err instanceof Error ? err.message : t("projects.groupCreateFailed"), + ); + } + }; + + const actionBar = ( + + + + + + + ); + return ( + + {actionBar} setView(String(value) as WorkspaceView)} options={[ + { value: "conversations", label: t("projects.tabConversations") }, { value: "projects", label: t("projects.tabProjects") }, { value: "tasks", label: t("projects.tabTasks") }, - { value: "conversations", label: t("projects.tabConversations") }, ]} /> - {view === "projects" ? ( - - ) : null} - {view === "conversations" ? ( - - ) : null} } > @@ -143,7 +345,7 @@ export default function ProjectsPage() { emptyText: ( } - description={t("projects.conversationsEmpty")} + description={t("projects.liveEmpty")} /> ), }} @@ -153,15 +355,24 @@ export default function ProjectsPage() { , ]} > + {item.title} + + {item.kind === "group" + ? t("projects.kindGroup") + : t("projects.kindDirect")} + + + } + description={item.subtitle} /> )} @@ -186,7 +397,7 @@ export default function ProjectsPage() { , @@ -241,8 +452,53 @@ export default function ProjectsPage() { > - - + + + + + + + + + + + + setGroupOpen(false)} + onOk={() => void createGroup()} + okText={t("common.create")} + > +
+ + + + { + if (!value || value.length < 2) { + throw new Error(t("projects.groupMembersRequired")); + } + }, + }, + ]} + > + ({ + label: agent.name, + value: agent.agent_id, + }))} + />
diff --git a/dashboard/src/utils/desktopFolder.test.ts b/dashboard/src/utils/desktopFolder.test.ts new file mode 100644 index 00000000..a92304c8 --- /dev/null +++ b/dashboard/src/utils/desktopFolder.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { canPickDesktopFolder, pickDesktopFolder } from "./desktopFolder"; + +describe("desktopFolder", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("is unavailable without the Wails bridge", () => { + expect(canPickDesktopFolder({} as Window)).toBe(false); + }); + + it("resolves the native folder path from the desktop event", async () => { + const invoke = vi.fn(() => { + window.dispatchEvent( + new CustomEvent("freeos-folder-selected", { + detail: "D:\\Projects\\demo", + }), + ); + }); + const win = { + _wails: { invoke }, + addEventListener: window.addEventListener.bind(window), + removeEventListener: window.removeEventListener.bind(window), + } as unknown as Window & { _wails?: { invoke?: (m: string) => void } }; + const path = await pickDesktopFolder(win, 1000); + expect(invoke).toHaveBeenCalledWith("wails:event:emit:desktop:select-folder"); + expect(path).toBe("D:\\Projects\\demo"); + }); + + it("returns null when the user cancels", async () => { + const invoke = vi.fn(() => { + window.dispatchEvent(new CustomEvent("freeos-folder-selected", { detail: "" })); + }); + const win = { + _wails: { invoke }, + addEventListener: window.addEventListener.bind(window), + removeEventListener: window.removeEventListener.bind(window), + } as unknown as Window & { _wails?: { invoke?: (m: string) => void } }; + await expect(pickDesktopFolder(win, 1000)).resolves.toBeNull(); + }); +}); diff --git a/dashboard/src/utils/desktopFolder.ts b/dashboard/src/utils/desktopFolder.ts new file mode 100644 index 00000000..6b7ada04 --- /dev/null +++ b/dashboard/src/utils/desktopFolder.ts @@ -0,0 +1,44 @@ +import { isDesktopShell } from "./desktopChrome"; + +type DesktopWindow = Window & { + _wails?: { invoke?: (message: string) => void }; +}; + +const FOLDER_EVENT = "freeos-folder-selected"; + +/** + * Native OS folder picker via the Wails desktop bridge. + * Returns null when cancelled, not in the desktop shell, or on timeout. + */ +export function pickDesktopFolder( + win: DesktopWindow = window as DesktopWindow, + timeoutMs = 120_000, +): Promise { + const invoke = win._wails?.invoke; + if (typeof invoke !== "function") { + return Promise.resolve(null); + } + return new Promise((resolve) => { + let settled = false; + const finish = (path: string | null) => { + if (settled) return; + settled = true; + win.removeEventListener(FOLDER_EVENT, onSelected as EventListener); + window.clearTimeout(timer); + resolve(path); + }; + const onSelected = (event: Event) => { + const detail = (event as CustomEvent).detail; + finish(typeof detail === "string" && detail.trim() ? detail.trim() : null); + }; + const timer = window.setTimeout(() => finish(null), timeoutMs); + win.addEventListener(FOLDER_EVENT, onSelected as EventListener); + invoke("wails:event:emit:desktop:select-folder"); + }); +} + +export function canPickDesktopFolder( + win: Pick = window as DesktopWindow, +): boolean { + return isDesktopShell(win); +} diff --git a/dashboard/src/utils/groupChats.test.ts b/dashboard/src/utils/groupChats.test.ts new file mode 100644 index 00000000..4895ce1c --- /dev/null +++ b/dashboard/src/utils/groupChats.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { + GROUP_CHATS_KEY, + groupChatTitle, + loadGroupChats, + saveGroupChat, + type GroupChatRecord, +} from "./groupChats"; + +function memoryStorage(initial: Record = {}) { + const data = { ...initial }; + return { + getItem: (key: string) => data[key] ?? null, + setItem: (key: string, value: string) => { + data[key] = value; + }, + data, + }; +} + +const sample: GroupChatRecord = { + id: "g1", + threadId: "t1", + hostAgentId: "a1", + memberIds: ["a1", "a2"], + title: "周会", + createdAt: 1, + lastActive: 2, +}; + +describe("groupChats", () => { + it("round-trips records through storage", () => { + const storage = memoryStorage(); + saveGroupChat(sample, storage); + expect(loadGroupChats(storage)).toEqual([sample]); + expect(storage.data[GROUP_CHATS_KEY]).toContain("周会"); + }); + + it("builds a default title from member names", () => { + expect(groupChatTitle("", ["分析师", "研究员"])).toBe("分析师、研究员"); + expect(groupChatTitle(" 产品 ", ["A"])).toBe("产品"); + }); +}); diff --git a/dashboard/src/utils/groupChats.ts b/dashboard/src/utils/groupChats.ts new file mode 100644 index 00000000..5da3e1ab --- /dev/null +++ b/dashboard/src/utils/groupChats.ts @@ -0,0 +1,71 @@ +export interface GroupChatRecord { + id: string; + threadId: string; + hostAgentId: string; + memberIds: string[]; + title: string; + createdAt: number; + lastActive: number; +} + +export const GROUP_CHATS_KEY = "freeos:group-chats"; + +export function loadGroupChats( + storage: Pick = window.localStorage, +): GroupChatRecord[] { + try { + const raw = storage.getItem(GROUP_CHATS_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.filter(isGroupChatRecord); + } catch { + return []; + } +} + +export function saveGroupChat( + record: GroupChatRecord, + storage: Pick = window.localStorage, +): GroupChatRecord[] { + const next = [ + record, + ...loadGroupChats(storage).filter((item) => item.id !== record.id), + ].sort((a, b) => b.lastActive - a.lastActive); + storage.setItem(GROUP_CHATS_KEY, JSON.stringify(next)); + return next; +} + +export function touchGroupChat( + threadId: string, + storage: Pick = window.localStorage, +): GroupChatRecord[] { + const current = loadGroupChats(storage); + const now = Date.now(); + const next = current.map((item) => + item.threadId === threadId ? { ...item, lastActive: now } : item, + ); + storage.setItem(GROUP_CHATS_KEY, JSON.stringify(next)); + return next; +} + +export function groupChatTitle( + title: string | undefined, + memberNames: string[], +): string { + const trimmed = title?.trim() ?? ""; + if (trimmed) return trimmed; + if (memberNames.length === 0) return "群聊"; + return memberNames.slice(0, 4).join("、"); +} + +function isGroupChatRecord(value: unknown): value is GroupChatRecord { + if (!value || typeof value !== "object") return false; + const row = value as GroupChatRecord; + return ( + typeof row.id === "string" && + typeof row.threadId === "string" && + typeof row.hostAgentId === "string" && + Array.isArray(row.memberIds) + ); +} diff --git a/desktop/README.md b/desktop/README.md index a8e14529..1ba38042 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -22,6 +22,10 @@ Same precedence as the FreeOS CLI/server: - Green runtime extract → `{home}/portable/` - Organization sidecar data → `{home}/org-os/` +- Local openXYOS workdir (FE+BE) → `%LOCALAPPDATA%\FreeOS\openxyos` on Windows, + else `{home}/openxyos`. The NSIS installer also expands a prebuilt + `openxyos-runtime.zip` into `$INSTDIR\openxyos`. First unelevated start copies + that tree into the workdir and starts `http://127.0.0.1:3780`. - Shell prefs → `{home}/desktop-settings.json` ## Windows install finish @@ -33,6 +37,16 @@ unelevated explorer token so the first run does not stamp `%USERPROFILE%\.freeos as High integrity. Uncheck to skip. Chinese installer strings are compiled with `makensis -INPUTCHARSET UTF8` from a UTF-8 BOM `project.nsi`. +The install log is no longer only `FreeOS.exe` + shortcuts. A healthy package +also copies `openxyos-runtime.zip` (prebuilt Node + openXYOS frontend/backend) +and extracts it to `$INSTDIR\openxyos`. The durable workdir is +`%LOCALAPPDATA%\FreeOS\openxyos` (and `{home}/openxyos`). Program Files is +read-only, so first start — not the elevated installer — writes the user +workdir. Organization then embeds `http://127.0.0.1:3780` without a manual +「启动边车」 click. + +Override the workdir with `FREEOS_OPENXYOS_HOME`. + ## Windows uninstall The NSIS uninstaller (Settings → Apps, or `uninstall.exe` in the install @@ -72,9 +86,13 @@ would treat that tree as program files (unless it is named `User Data` / On first open the shell: 1. Extracts the bundled portable runtime (Python host + Node + openXYOS). -2. Starts the openXYOS sidecar on `http://127.0.0.1:3780` when `org-sidecar/` is present. -3. Starts the FreeOS host with `FREEOS_HOME` and `FREEOS_ORG_ENABLE=1`. -4. Opens the desktop window on the host UI with a local guest session (no +2. Provisions the openXYOS workdir (`%LOCALAPPDATA%\FreeOS\openxyos` or + `{home}/openxyos`) from `$INSTDIR\openxyos` or `portable/org-sidecar`. +3. Starts the openXYOS sidecar on `http://127.0.0.1:3780` and waits until + `/api/health/livez` responds (first launch allows extra time). +4. Starts the FreeOS host with `FREEOS_HOME`, `FREEOS_ORG_ENABLE=1`, and + `FREEOS_OPENXYOS_HOME`. +5. Opens the desktop window on the host UI with a local guest session (no login wall). Chat and **Organization** are both available — no separate Node install. Register or sign in later when a save needs an account. diff --git a/desktop/portable/package.sh b/desktop/portable/package.sh index e040aedb..c6e64a4e 100755 --- a/desktop/portable/package.sh +++ b/desktop/portable/package.sh @@ -243,6 +243,27 @@ assemble_one() { echo "[package] ${plat}: bundling openXYOS sidecar" >&2 bash "${REPO_ROOT}/desktop/portable/bundle-org-sidecar.sh" "$plat" "$staging" + if [[ "${SKIP_ORG_SIDECAR:-0}" != "1" ]]; then + if [[ ! -f "${staging}/org-sidecar/openxyos/dist/index.html" ]]; then + echo "[package] ERROR: openXYOS frontend missing under ${staging}/org-sidecar/openxyos/dist" >&2 + exit 1 + fi + if [[ "$plat" == windows-* && ! -f "${staging}/org-sidecar/node/node.exe" ]]; then + echo "[package] ERROR: bundled Node missing under ${staging}/org-sidecar/node" >&2 + exit 1 + fi + if [[ "$plat" != windows-* && ! -x "${staging}/org-sidecar/node/bin/node" ]]; then + echo "[package] ERROR: bundled Node missing under ${staging}/org-sidecar/node/bin" >&2 + exit 1 + fi + echo "[package] ${plat}: writing openxyos-runtime zip" >&2 + python3 "${REPO_ROOT}/desktop/src/build/stage_openxyos_runtime.py" \ + "${staging}/org-sidecar" \ + "${GREEN_RELEASE}/openxyos-runtime-${plat}-$(octop_version).zip" \ + || python "${REPO_ROOT}/desktop/src/build/stage_openxyos_runtime.py" \ + "${staging}/org-sidecar" \ + "${GREEN_RELEASE}/openxyos-runtime-${plat}-$(octop_version).zip" + fi # Windows: pywin32 DLLs must be findable next to python.exe (or on PATH). # --target installs leave them under packages/pywin32_system32 only. diff --git a/desktop/src/.gitignore b/desktop/src/.gitignore index c51fe70d..ce9d7729 100644 --- a/desktop/src/.gitignore +++ b/desktop/src/.gitignore @@ -8,4 +8,6 @@ build/windows/icon.ico build/windows/info.generated.json build/windows/wails.exe.generated.manifest build/windows/nsis/MicrosoftEdgeWebview2Setup.exe +build/windows/openxyos-runtime.zip +build/windows/openxyos-README.txt build/linux/icon.png diff --git a/desktop/src/build/stage_openxyos_runtime.py b/desktop/src/build/stage_openxyos_runtime.py new file mode 100644 index 00000000..d3d563ab --- /dev/null +++ b/desktop/src/build/stage_openxyos_runtime.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Build openxyos-runtime.zip from a green portable zip or staging tree. + +The Windows NSIS installer copies this zip into $INSTDIR and expands it to +$INSTDIR\\openxyos so the finish log shows FE+BE, not only FreeOS.exe. +""" + +from __future__ import annotations + +import argparse +import shutil +import sys +import zipfile +from pathlib import Path + +REQUIRED = ( + Path("node"), + Path("openxyos") / "backend" / "server.ts", + Path("openxyos") / "dist" / "index.html", +) + + +def _bundle_root(extracted: Path) -> Path | None: + direct = extracted + if _ready(direct): + return direct + nested = extracted / "org-sidecar" + if _ready(nested): + return nested + for child in extracted.iterdir(): + if not child.is_dir(): + continue + if _ready(child): + return child + inner = child / "org-sidecar" + if _ready(inner): + return inner + return None + + +def _ready(root: Path) -> bool: + return all((root / part).exists() for part in REQUIRED) + + +def _extract_source(source: Path, work: Path) -> Path: + if source.is_dir(): + return source + dest = work / "unpacked" + if dest.exists(): + shutil.rmtree(dest) + dest.mkdir(parents=True) + with zipfile.ZipFile(source) as zf: + zf.extractall(dest) + return dest + + +def stage(source: Path, dest_zip: Path) -> Path: + work = dest_zip.parent / ".openxyos-runtime-work" + work.mkdir(parents=True, exist_ok=True) + extracted = _extract_source(source, work) + root = _bundle_root(extracted) + if root is None: + raise SystemExit(f"openXYOS sidecar (node + FE + BE) not found in {source}") + dest_zip.parent.mkdir(parents=True, exist_ok=True) + if dest_zip.exists(): + dest_zip.unlink() + with zipfile.ZipFile(dest_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for path in sorted(root.rglob("*")): + if path.is_dir(): + continue + rel = path.relative_to(root).as_posix() + zf.write(path, rel) + readme = dest_zip.with_name("openxyos-README.txt") + readme.write_text( + "FreeOS openXYOS runtime\n" + "Install dir: \\openxyos (FE+BE, read-only)\n" + "Work dir: %LOCALAPPDATA%\\FreeOS\\openxyos or %USERPROFILE%\\.freeos\\openxyos\n" + "URL: http://127.0.0.1:3780\n", + encoding="utf-8", + ) + if work.exists() and source.is_file(): + shutil.rmtree(work, ignore_errors=True) + return dest_zip + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("source", help="portable zip or org-sidecar staging directory") + parser.add_argument("dest", help="output openxyos-runtime.zip") + args = parser.parse_args() + dest = stage(Path(args.source), Path(args.dest)) + print(dest) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/desktop/src/build/windows/Taskfile.yml b/desktop/src/build/windows/Taskfile.yml index 37dedb46..bd6ff0bc 100644 --- a/desktop/src/build/windows/Taskfile.yml +++ b/desktop/src/build/windows/Taskfile.yml @@ -40,6 +40,20 @@ tasks: vars: PORTABLE_ZIP: '{{.PORTABLE_ZIP | default (printf "../portable/release/FreeOS-portable-windows-%s-%s.zip" (.ARCH | default ARCH) (.VERSION | default "dev"))}}' + stage:openxyos-runtime: + summary: Extract org-sidecar from the portable zip for the NSIS payload + cmds: + - cmd: >- + python "{{.ROOT_DIR}}/build/stage_openxyos_runtime.py" "{{.PORTABLE_ZIP}}" + "{{.ROOT_DIR}}/build/windows/openxyos-runtime.zip" + platforms: [windows] + - cmd: >- + python3 "{{.ROOT_DIR}}/build/stage_openxyos_runtime.py" "{{.PORTABLE_ZIP}}" + "{{.ROOT_DIR}}/build/windows/openxyos-runtime.zip" + platforms: [linux, darwin] + vars: + PORTABLE_ZIP: '{{.PORTABLE_ZIP | default (printf "../portable/release/FreeOS-portable-windows-%s-%s.zip" (.ARCH | default ARCH) (.VERSION | default "dev"))}}' + package: summary: Packages an NSIS installer with the portable runtime embedded cmds: @@ -47,6 +61,11 @@ tasks: vars: ARCH: "{{.ARCH}}" PORTABLE_ZIP: "{{.PORTABLE_ZIP}}" + - task: stage:openxyos-runtime + vars: + ARCH: "{{.ARCH}}" + PORTABLE_ZIP: "{{.PORTABLE_ZIP}}" + VERSION: "{{.VERSION}}" - task: build vars: PRODUCTION: "true" diff --git a/desktop/src/build/windows/nsis/project.nsi b/desktop/src/build/windows/nsis/project.nsi index 3282ecfe..2d863783 100644 --- a/desktop/src/build/windows/nsis/project.nsi +++ b/desktop/src/build/windows/nsis/project.nsi @@ -52,6 +52,22 @@ ManifestDPIAware true LangString FINISH_RUN ${LANG_SIMPCHINESE} "运行 FreeOS" LangString FINISH_RUN ${LANG_ENGLISH} "Run FreeOS" +LangString OPENXYOS_WORKDIR ${LANG_SIMPCHINESE} "创建 openXYOS 工作目录(安装目录与 %LOCALAPPDATA%\\FreeOS\\openxyos)" +LangString OPENXYOS_WORKDIR ${LANG_ENGLISH} "Create the openXYOS work directory (install dir and %LOCALAPPDATA%\\FreeOS\\openxyos)" +LangString OPENXYOS_COPY_ZIP ${LANG_SIMPCHINESE} "复制 openXYOS 运行包(前端+后端)" +LangString OPENXYOS_COPY_ZIP ${LANG_ENGLISH} "Copy the openXYOS runtime payload (frontend + backend)" +LangString OPENXYOS_EXTRACT ${LANG_SIMPCHINESE} "解压 openXYOS 前端与后端到安装目录\\openxyos" +LangString OPENXYOS_EXTRACT ${LANG_ENGLISH} "Extract openXYOS frontend and backend into the install dir\\openxyos" +LangString OPENXYOS_FE_OK ${LANG_SIMPCHINESE} "已就绪:openxyos\\dist\\index.html" +LangString OPENXYOS_FE_OK ${LANG_ENGLISH} "Ready: openxyos\\dist\\index.html" +LangString OPENXYOS_FE_MISSING ${LANG_SIMPCHINESE} "警告:未找到 openXYOS 前端构建,首次启动将再试" +LangString OPENXYOS_FE_MISSING ${LANG_ENGLISH} "Warning: openXYOS frontend build missing; first launch will retry" +LangString OPENXYOS_NODE_OK ${LANG_SIMPCHINESE} "已就绪:bundled Node(node.exe)" +LangString OPENXYOS_NODE_OK ${LANG_ENGLISH} "Ready: bundled Node (node.exe)" +LangString OPENXYOS_NODE_MISSING ${LANG_SIMPCHINESE} "警告:未找到 bundled Node,首次启动将再试" +LangString OPENXYOS_NODE_MISSING ${LANG_ENGLISH} "Warning: bundled Node missing; first launch will retry" +LangString OPENXYOS_ZIP_MISSING ${LANG_SIMPCHINESE} "安装包未含独立 openXYOS zip;首次启动会从内置 portable 解压" +LangString OPENXYOS_ZIP_MISSING ${LANG_ENGLISH} "Installer has no separate openXYOS zip; first launch unpacks the bundled portable" LangString UN_FREEOS_RUNNING ${LANG_SIMPCHINESE} "检测到 FreeOS 仍在运行(主程序、主机或组织侧车)。$\r$\n$\r$\n继续将结束这些进程,并删除安装目录中的程序文件。$\r$\n用户数据(%USERPROFILE%\.freeos)会保留。$\r$\n$\r$\n要继续卸载吗?" LangString UN_FREEOS_RUNNING ${LANG_ENGLISH} "FreeOS is still running (shell, host, or organization sidecar).$\r$\n$\r$\nContinuing will stop those processes and remove program files from the install folder.$\r$\nUser data (%USERPROFILE%\.freeos) is kept.$\r$\n$\r$\nContinue uninstall?" diff --git a/desktop/src/build/windows/nsis/wails_tools.nsh b/desktop/src/build/windows/nsis/wails_tools.nsh index 04a610d4..3916fb2b 100644 --- a/desktop/src/build/windows/nsis/wails_tools.nsh +++ b/desktop/src/build/windows/nsis/wails_tools.nsh @@ -120,6 +120,47 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}" ${EndIf} !endif + !insertmacro wails.provisionOpenXYOS +!macroend + +# Ship the prebuilt openXYOS FE+BE next to FreeOS.exe and expand it so the +# finish log is not just FreeOS.exe + shortcuts. First unelevated start also +# copies this tree into %LOCALAPPDATA%\FreeOS\openxyos (Program Files is read-only). +!macro wails.provisionOpenXYOS + SetDetailsPrint both + DetailPrint "$(OPENXYOS_WORKDIR)" + CreateDirectory "$INSTDIR\openxyos" + CreateDirectory "$LOCALAPPDATA\FreeOS\openxyos" + FileOpen $0 "$INSTDIR\openxyos\README.txt" w + FileWrite $0 "FreeOS local openXYOS environment$\r$\n" + FileWrite $0 "Install tree: $INSTDIR\openxyos$\r$\n" + FileWrite $0 "Work dir: $LOCALAPPDATA\FreeOS\openxyos$\r$\n" + FileWrite $0 "Also: %USERPROFILE%\.freeos\openxyos$\r$\n" + FileWrite $0 "URL: http://127.0.0.1:3780$\r$\n" + FileClose $0 + !if /FileExists "..\openxyos-runtime.zip" + DetailPrint "$(OPENXYOS_COPY_ZIP)" + File "/oname=openxyos-runtime.zip" "..\openxyos-runtime.zip" + DetailPrint "$(OPENXYOS_EXTRACT)" + nsExec::ExecToLog 'powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Expand-Archive -LiteralPath ''$INSTDIR\openxyos-runtime.zip'' -DestinationPath ''$INSTDIR\openxyos'' -Force"' + IfFileExists "$INSTDIR\openxyos\openxyos\dist\index.html" openxyosFeOk openxyosFeMissing + openxyosFeOk: + DetailPrint "$(OPENXYOS_FE_OK)" + Goto openxyosFeDone + openxyosFeMissing: + DetailPrint "$(OPENXYOS_FE_MISSING)" + openxyosFeDone: + IfFileExists "$INSTDIR\openxyos\node\node.exe" openxyosNodeOk openxyosNodeMissing + openxyosNodeOk: + DetailPrint "$(OPENXYOS_NODE_OK)" + Goto openxyosNodeDone + openxyosNodeMissing: + DetailPrint "$(OPENXYOS_NODE_MISSING)" + openxyosNodeDone: + !else + DetailPrint "$(OPENXYOS_ZIP_MISSING)" + !endif + SetDetailsPrint listonly !macroend !macro wails.writeUninstaller diff --git a/desktop/src/desktop_copy.go b/desktop/src/desktop_copy.go index 10b8a0d7..f8d87d58 100644 --- a/desktop/src/desktop_copy.go +++ b/desktop/src/desktop_copy.go @@ -7,6 +7,7 @@ const ( copyStatusCheckingRuntime = "status.checking_runtime" copyStatusStartingService = "status.starting_service" copyStatusStartingOrg = "status.starting_org" + copyStatusProvisioningOrg = "status.provisioning_org" copyStatusReady = "status.ready" copyStatusUsingRuntime = "status.using_runtime" copyStatusBackupDatabase = "status.backup_database" @@ -35,6 +36,7 @@ var desktopCopy = map[Locale]map[string]string{ copyStatusCheckingRuntime: "Checking the runtime…", copyStatusStartingService: "Starting the FreeOS service…", copyStatusStartingOrg: "Starting the organization console…", + copyStatusProvisioningOrg: "Preparing the local openXYOS environment…", copyStatusReady: "FreeOS is ready", copyStatusUsingRuntime: "Using the existing runtime…", copyStatusBackupDatabase: "Desktop update %s found. Backing up the database…", @@ -61,6 +63,7 @@ var desktopCopy = map[Locale]map[string]string{ copyStatusCheckingRuntime: "正在检查运行环境…", copyStatusStartingService: "正在启动 FreeOS 服务…", copyStatusStartingOrg: "正在启动组织控制台…", + copyStatusProvisioningOrg: "正在准备本机 openXYOS 环境…", copyStatusReady: "FreeOS 已就绪", copyStatusUsingRuntime: "正在使用已有运行环境…", copyStatusBackupDatabase: "发现客户端新版 %s,正在备份数据库…", diff --git a/desktop/src/folder_dialog.go b/desktop/src/folder_dialog.go new file mode 100644 index 00000000..374fead6 --- /dev/null +++ b/desktop/src/folder_dialog.go @@ -0,0 +1,113 @@ +package main + +import ( + "fmt" + "log" + "os/exec" + "runtime" + "strings" +) + +func (a *App) emitPickedFolder() { + path, err := pickFolderNative() + if err != nil { + log.Printf("select folder: %v", err) + path = "" + } + if a.window == nil { + return + } + js := fmt.Sprintf( + `window.dispatchEvent(new CustomEvent('freeos-folder-selected',{detail:%s}))`, + jsonString(path), + ) + a.window.ExecJS(js) +} + +// SelectFolder opens a native OS folder picker (any drive on Windows). +func (a *App) SelectFolder() (string, error) { + return pickFolderNative() +} + +func pickFolderNative() (string, error) { + switch runtime.GOOS { + case "windows": + return pickFolderWindows() + case "darwin": + return pickFolderDarwin() + default: + return pickFolderLinux() + } +} + +func pickFolderWindows() (string, error) { + // FolderBrowserDialog with MyComputer so every drive letter is reachable. + script := strings.Join( + []string{ + "Add-Type -AssemblyName System.Windows.Forms", + "[void][System.Windows.Forms.Application]::EnableVisualStyles()", + "$d = New-Object System.Windows.Forms.FolderBrowserDialog", + "$d.Description = 'Select a work folder'", + "$d.RootFolder = [System.Environment+SpecialFolder]::MyComputer", + "$d.ShowNewFolderButton = $true", + "if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $d.SelectedPath }", + }, + "; ", + ) + cmd := exec.Command( + "powershell.exe", + "-NoProfile", + "-STA", + "-ExecutionPolicy", "Bypass", + "-Command", + script, + ) + hideConsole(cmd) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("folder dialog: %w", err) + } + path := strings.TrimSpace(string(out)) + if path == "" { + return "", nil + } + return path, nil +} + +func pickFolderDarwin() (string, error) { + cmd := exec.Command( + "osascript", + "-e", + `POSIX path of (choose folder with prompt "Select a work folder")`, + ) + out, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return "", nil + } + return "", fmt.Errorf("folder dialog: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +func pickFolderLinux() (string, error) { + for _, argv := range [][]string{ + {"zenity", "--file-selection", "--directory", "--title=Select a work folder"}, + {"kdialog", "--getexistingdirectory", ".", "Select a work folder"}, + } { + if _, err := exec.LookPath(argv[0]); err != nil { + continue + } + cmd := exec.Command(argv[0], argv[1:]...) + out, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return "", nil + } + log.Printf("folder dialog %s: %v", argv[0], err) + continue + } + return strings.TrimSpace(string(out)), nil + } + return "", fmt.Errorf("no folder dialog (install zenity or kdialog)") +} diff --git a/desktop/src/main.go b/desktop/src/main.go index 58f6701d..82c7d811 100644 --- a/desktop/src/main.go +++ b/desktop/src/main.go @@ -189,6 +189,9 @@ func (a *App) boot() { return } root := portableDir() + if _, perr := provisionOpenXYOS(root, locale, a.setStatus); perr != nil { + log.Printf("openXYOS provision: %v", perr) + } port := chooseHostPort(s.Port) if port != s.Port { log.Printf("port %d is occupied; starting FreeOS host on %d", s.Port, port) @@ -203,8 +206,6 @@ func (a *App) boot() { if serr != nil { logStartupError("organization sidecar", serr) } - // Sidecar health is non-blocking: Node/tsx cold start must not hold - // the splash. Dashboard Organization polls until livez is up. } else { log.Printf("organization sidecar not bundled under %s", orgSidecarDir(root)) } @@ -229,6 +230,17 @@ func (a *App) boot() { showFatalError("FreeOS", formatFatalStartup(locale, err)) return } + if sidecarReady(root) { + wait := 20 * time.Second + if firstLaunch { + wait = 45 * time.Second + } + if err := waitSidecarLive(wait); err != nil { + log.Printf("organization sidecar not live yet: %v", err) + } else { + log.Printf("organization sidecar live at %s", sidecarURL()) + } + } a.showDashboard(base) } @@ -431,6 +443,9 @@ func main() { app.Event.On("desktop:close", func(_ *application.CustomEvent) { api.hideToTray() }) + app.Event.On("desktop:select-folder", func(_ *application.CustomEvent) { + go api.emitPickedFolder() + }) tray := app.SystemTray.New() applyTrayIcon(tray) tray.SetTooltip("FreeOS") diff --git a/desktop/src/process.go b/desktop/src/process.go index 907e94f6..76bfe7e9 100644 --- a/desktop/src/process.go +++ b/desktop/src/process.go @@ -21,7 +21,7 @@ const defaultSidecarPort = 3780 func hostLaunchEnv(root string, port int) map[string]string { home := productHome() - return map[string]string{ + env := map[string]string{ "FREEOS_HOME": home, "OCTOP_HOME": home, "OCTOP_GREEN_PACKAGES": filepath.Join(root, "packages"), @@ -35,6 +35,10 @@ func hostLaunchEnv(root string, port int) map[string]string { "OCTOP_DESKTOP": "1", "FREEOS_DESKTOP": "1", } + if bundle := resolveSidecarDir(root); bundle != "" { + env["FREEOS_OPENXYOS_HOME"] = bundle + } + return env } func sidecarURL() string { diff --git a/desktop/src/process_sidecar.go b/desktop/src/process_sidecar.go index f41e0de5..71d0bb4a 100644 --- a/desktop/src/process_sidecar.go +++ b/desktop/src/process_sidecar.go @@ -1,9 +1,11 @@ package main import ( + "archive/zip" "crypto/rand" "encoding/hex" "fmt" + "io" "log" "net/http" "os" @@ -20,15 +22,23 @@ func orgSidecarDir(root string) string { } func sidecarNodeExe(root string) string { - base := filepath.Join(orgSidecarDir(root), "node") + return sidecarNodeAt(orgSidecarDir(root)) +} + +func sidecarAppDir(root string) string { + return sidecarAppAt(orgSidecarDir(root)) +} + +func sidecarNodeAt(bundle string) string { + base := filepath.Join(bundle, "node") if runtime.GOOS == "windows" { return filepath.Join(base, "node.exe") } return filepath.Join(base, "bin", "node") } -func sidecarAppDir(root string) string { - return filepath.Join(orgSidecarDir(root), "openxyos") +func sidecarAppAt(bundle string) string { + return filepath.Join(bundle, "openxyos") } func sidecarNodeArgs(app string) []string { @@ -39,11 +49,14 @@ func sidecarNodeArgs(app string) []string { return []string{"--import", "tsx", "backend/server.ts"} } -func sidecarReady(root string) bool { - if _, err := os.Stat(sidecarNodeExe(root)); err != nil { +func sidecarBundleReady(bundle string) bool { + if strings.TrimSpace(bundle) == "" { return false } - app := sidecarAppDir(root) + if _, err := os.Stat(sidecarNodeAt(bundle)); err != nil { + return false + } + app := sidecarAppAt(bundle) if _, err := os.Stat(filepath.Join(app, "backend", "server.ts")); err != nil { return false } @@ -54,6 +67,72 @@ func sidecarReady(root string) bool { return true } +func sidecarReady(root string) bool { + return resolveSidecarDir(root) != "" +} + +func openxyosUserWorkDir() string { + if v := strings.TrimSpace(os.Getenv("FREEOS_OPENXYOS_HOME")); v != "" { + return v + } + if runtime.GOOS == "windows" { + if base := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); base != "" { + return filepath.Join(base, "FreeOS", "openxyos") + } + } + return filepath.Join(productHome(), "openxyos") +} + +func openxyosInstallDir() string { + exe, err := os.Executable() + if err != nil { + return "" + } + dir := filepath.Dir(exe) + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved + } + return filepath.Join(dir, "openxyos") +} + +func sidecarSearchDirs(portableRoot string) []string { + seen := map[string]bool{} + var out []string + add := func(p string) { + p = strings.TrimSpace(p) + if p == "" { + return + } + clean := filepath.Clean(p) + if seen[clean] { + return + } + seen[clean] = true + out = append(out, clean) + } + add(os.Getenv("FREEOS_OPENXYOS_HOME")) + add(openxyosUserWorkDir()) + add(filepath.Join(productHome(), "openxyos")) + add(openxyosInstallDir()) + if portableRoot != "" { + add(orgSidecarDir(portableRoot)) + } + return out +} + +func resolveSidecarDir(portableRoot string) string { + for _, dir := range sidecarSearchDirs(portableRoot) { + if sidecarBundleReady(dir) { + return dir + } + nested := filepath.Join(dir, "org-sidecar") + if sidecarBundleReady(nested) { + return nested + } + } + return "" +} + func waitSidecarLive(timeout time.Duration) error { deadline := time.Now().Add(timeout) url := sidecarURL() + "/api/health/livez" @@ -156,7 +235,8 @@ func sidecarLaunchEnv(home string, dashboardPort int) (map[string]string, error) } func startOrgSidecar(root string, dashboardPort int) (*exec.Cmd, error) { - if !sidecarReady(root) { + bundle := resolveSidecarDir(root) + if bundle == "" { return nil, nil } home := productHome() @@ -164,8 +244,9 @@ func startOrgSidecar(root string, dashboardPort int) (*exec.Cmd, error) { if err != nil { return nil, err } - app := sidecarAppDir(root) - node := sidecarNodeExe(root) + env["FREEOS_OPENXYOS_HOME"] = bundle + app := sidecarAppAt(bundle) + node := sidecarNodeAt(bundle) cmd := exec.Command(node, sidecarNodeArgs(app)...) cmd.Dir = app mustEnv(cmd, env) @@ -183,3 +264,224 @@ func startOrgSidecar(root string, dashboardPort int) (*exec.Cmd, error) { } return cmd, nil } + +func provisionOpenXYOS(portableRoot string, locale Locale, status func(string)) (string, error) { + dest := openxyosUserWorkDir() + if sidecarBundleReady(dest) { + _ = writeOpenXYOSReadme(dest) + return dest, nil + } + if status != nil { + status(desktopText(locale, copyStatusProvisioningOrg)) + } + if err := os.MkdirAll(dest, 0o755); err != nil { + return "", err + } + for _, source := range openxyosSourceDirs(portableRoot) { + if !sidecarBundleReady(source) { + continue + } + if filepath.Clean(source) == filepath.Clean(dest) { + _ = writeOpenXYOSReadme(dest) + return dest, nil + } + if err := copyTree(source, dest); err != nil { + log.Printf("copy openXYOS from %s: %v", source, err) + continue + } + if sidecarBundleReady(dest) { + _ = writeOpenXYOSReadme(dest) + log.Printf("openXYOS workdir ready at %s", dest) + return dest, nil + } + } + if zipPath := openxyosRuntimeZip(); zipPath != "" { + if err := unzipSidecarZip(zipPath, dest); err != nil { + log.Printf("extract openxyos-runtime.zip: %v", err) + } else if sidecarBundleReady(dest) { + _ = writeOpenXYOSReadme(dest) + log.Printf("openXYOS workdir extracted to %s", dest) + return dest, nil + } + } + if bundle := resolveSidecarDir(portableRoot); bundle != "" { + _ = writeOpenXYOSReadme(bundle) + return bundle, nil + } + _ = writeOpenXYOSReadme(dest) + return dest, fmt.Errorf("openXYOS runtime missing (expected node + FE + BE)") +} + +func openxyosSourceDirs(portableRoot string) []string { + var out []string + if install := openxyosInstallDir(); install != "" { + out = append(out, install) + } + if portableRoot != "" { + out = append(out, orgSidecarDir(portableRoot)) + } + return out +} + +func openxyosRuntimeZip() string { + exe, err := os.Executable() + if err != nil { + return "" + } + dir := filepath.Dir(exe) + for _, name := range []string{"openxyos-runtime.zip", "openXYOS-runtime.zip"} { + path := filepath.Join(dir, name) + if _, err := os.Stat(path); err == nil { + return path + } + } + return "" +} + +func writeOpenXYOSReadme(dir string) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + body := strings.Join([]string{ + "FreeOS local openXYOS environment", + "", + "This folder is the local organization console (frontend + backend).", + "FreeOS starts it automatically at http://127.0.0.1:3780", + "Data/SQLite lives under {FREEOS_HOME}/org-os/ (usually ~/.freeos/org-os).", + "", + "Windows workdir: %LOCALAPPDATA%\\FreeOS\\openxyos", + "Profile workdir: %USERPROFILE%\\.freeos\\openxyos", + "Installer copy: \\openxyos", + "", + }, "\n") + return os.WriteFile(filepath.Join(dir, "README.txt"), []byte(body), 0o644) +} + +func copyTree(src, dest string) error { + src = filepath.Clean(src) + dest = filepath.Clean(dest) + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dest, rel) + if info.IsDir() { + return os.MkdirAll(target, 0o755) + } + if info.Mode()&os.ModeSymlink != 0 { + link, err := os.Readlink(path) + if err != nil { + return err + } + _ = os.Remove(target) + return os.Symlink(link, target) + } + return copyFile(path, target, info.Mode()) + }) +} + +func copyFile(src, dest string, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) + if err != nil { + return err + } + _, copyErr := io.Copy(out, in) + closeErr := out.Close() + if copyErr != nil { + return copyErr + } + return closeErr +} + +func unzipSidecarZip(zipPath, dest string) error { + reader, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer reader.Close() + if err := os.MkdirAll(dest, 0o755); err != nil { + return err + } + prefix := sidecarZipPrefix(reader.File) + for _, file := range reader.File { + name := filepath.ToSlash(strings.ReplaceAll(file.Name, "\\", "/")) + rel := name + if prefix != "" { + if name == prefix || name == prefix+"/" { + continue + } + if !strings.HasPrefix(name, prefix+"/") { + continue + } + rel = strings.TrimPrefix(name, prefix+"/") + } + if rel == "" { + continue + } + target := filepath.Join(dest, filepath.FromSlash(rel)) + cleanDest := filepath.Clean(dest) + string(os.PathSeparator) + if !strings.HasPrefix(target, cleanDest) && target != filepath.Clean(dest) { + return fmt.Errorf("illegal zip path %s", name) + } + if file.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + rc, err := file.Open() + if err != nil { + return err + } + out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) + if err != nil { + rc.Close() + return err + } + _, err = io.Copy(out, rc) + out.Close() + rc.Close() + if err != nil { + return err + } + } + if sidecarBundleReady(filepath.Join(dest, "org-sidecar")) && !sidecarBundleReady(dest) { + return copyTree(filepath.Join(dest, "org-sidecar"), dest) + } + return nil +} + +func sidecarZipPrefix(files []*zip.File) string { + var top string + for _, file := range files { + name := filepath.ToSlash(strings.ReplaceAll(file.Name, "\\", "/")) + if name == "" { + continue + } + first := strings.SplitN(name, "/", 2)[0] + if first == "node" || first == "openxyos" || first == "start-sidecar.bat" || first == "start-sidecar.sh" { + return "" + } + if top == "" { + top = first + } else if first != top { + return "" + } + } + return top +} diff --git a/desktop/src/process_sidecar_test.go b/desktop/src/process_sidecar_test.go index 57b2f809..935f15a9 100644 --- a/desktop/src/process_sidecar_test.go +++ b/desktop/src/process_sidecar_test.go @@ -49,6 +49,9 @@ func TestSidecarLaunchEnvPointsAtHomeDatabase(t *testing.T) { func TestSidecarReadyRequiresFrontendBuild(t *testing.T) { root := t.TempDir() + t.Setenv("FREEOS_HOME", root) + t.Setenv("FREEOS_OPENXYOS_HOME", filepath.Join(root, "missing-openxyos")) + t.Setenv("LOCALAPPDATA", filepath.Join(root, "local")) if sidecarReady(root) { t.Fatal("empty tree should not be ready") } @@ -80,6 +83,65 @@ func TestSidecarReadyRequiresFrontendBuild(t *testing.T) { } } +func TestResolveSidecarDirUsesWorkdir(t *testing.T) { + root := t.TempDir() + work := filepath.Join(root, "openxyos") + t.Setenv("FREEOS_HOME", root) + t.Setenv("FREEOS_OPENXYOS_HOME", work) + t.Setenv("LOCALAPPDATA", filepath.Join(root, "local")) + writeSidecarBundle(t, work) + if got := resolveSidecarDir(filepath.Join(root, "portable")); got != work { + t.Fatalf("resolveSidecarDir=%q want %q", got, work) + } +} + +func TestProvisionOpenXYOSCopiesFromPortable(t *testing.T) { + root := t.TempDir() + t.Setenv("FREEOS_HOME", root) + work := filepath.Join(root, "workdir") + t.Setenv("FREEOS_OPENXYOS_HOME", work) + t.Setenv("LOCALAPPDATA", filepath.Join(root, "local")) + portable := filepath.Join(root, "portable") + writeSidecarBundle(t, orgSidecarDir(portable)) + got, err := provisionOpenXYOS(portable, LocaleEN, nil) + if err != nil { + t.Fatal(err) + } + if got != work { + t.Fatalf("provision dest=%q want %q", got, work) + } + if !sidecarBundleReady(work) { + t.Fatal("workdir should contain FE+BE") + } + if _, err := os.Stat(filepath.Join(work, "README.txt")); err != nil { + t.Fatal(err) + } +} + +func writeSidecarBundle(t *testing.T, bundle string) { + t.Helper() + node := sidecarNodeAt(bundle) + if err := os.MkdirAll(filepath.Dir(node), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(node, []byte("node"), 0o755); err != nil { + t.Fatal(err) + } + app := sidecarAppAt(bundle) + if err := os.MkdirAll(filepath.Join(app, "backend"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(app, "backend", "server.ts"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(app, "dist"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(app, "dist", "index.html"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } +} + func TestSidecarNodeArgsPrefersCompiledServer(t *testing.T) { app := t.TempDir() args := sidecarNodeArgs(app) diff --git a/desktop/src/process_unix.go b/desktop/src/process_unix.go index 68d45c53..cd03ff98 100644 --- a/desktop/src/process_unix.go +++ b/desktop/src/process_unix.go @@ -7,6 +7,8 @@ import ( "syscall" ) +func hideConsole(cmd *exec.Cmd) {} + func configureProcGroup(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} } diff --git a/desktop/src/settings_home_test.go b/desktop/src/settings_home_test.go index 8fc8c7a6..7c4e5992 100644 --- a/desktop/src/settings_home_test.go +++ b/desktop/src/settings_home_test.go @@ -67,6 +67,9 @@ func TestWithDesktopQueryMarksSpa(t *testing.T) { func TestSidecarReadyRequiresNodeAndServer(t *testing.T) { root := t.TempDir() + t.Setenv("FREEOS_HOME", root) + t.Setenv("FREEOS_OPENXYOS_HOME", filepath.Join(root, "missing")) + t.Setenv("LOCALAPPDATA", filepath.Join(root, "local")) if sidecarReady(root) { t.Fatal("empty tree should not look ready") } diff --git a/src/octop/modules/org_os/sidecar_launch.py b/src/octop/modules/org_os/sidecar_launch.py index 8022d462..d611aca7 100644 --- a/src/octop/modules/org_os/sidecar_launch.py +++ b/src/octop/modules/org_os/sidecar_launch.py @@ -71,6 +71,22 @@ def compiled_server(self) -> Path: return self.app / "backend-dist" / "server.js" +def _explicit_openxyos_home() -> Path | None: + raw = (os.environ.get("FREEOS_OPENXYOS_HOME") or "").strip() + if not raw: + return None + path = Path(raw).expanduser() + return path if path.is_dir() else None + + +def _localappdata_openxyos() -> Path | None: + base = (os.environ.get("LOCALAPPDATA") or "").strip() + if not base: + return None + path = Path(base) / "FreeOS" / "openxyos" + return path if path.is_dir() else None + + def portable_root() -> Path | None: green = (os.environ.get("OCTOP_GREEN_PACKAGES") or "").strip() if green: @@ -85,6 +101,18 @@ def portable_root() -> Path | None: def sidecar_bundle_dir() -> Path | None: + for candidate in ( + _explicit_openxyos_home(), + _localappdata_openxyos(), + PathLayout.from_env().root / "openxyos", + ): + if candidate is None: + continue + if _bundle_looks_complete(candidate): + return candidate + nested = candidate / "org-sidecar" + if _bundle_looks_complete(nested): + return nested root = portable_root() if root is None: return None @@ -92,6 +120,15 @@ def sidecar_bundle_dir() -> Path | None: return bundled if bundled.is_dir() else None +def _bundle_looks_complete(path: Path) -> bool: + if not path.is_dir(): + return False + node = path / "node" / "node.exe" + posix = path / "node" / "bin" / "node" + app = path / "openxyos" + return (node.is_file() or posix.is_file()) and (app / "backend" / "server.ts").is_file() + + def find_sidecar_runtime() -> SidecarRuntime | None: bundled = sidecar_bundle_dir() if bundled is None: diff --git a/tests/unit/desktop/test_nsis_uninstall.py b/tests/unit/desktop/test_nsis_uninstall.py index e388b86f..d9e2f1e1 100644 --- a/tests/unit/desktop/test_nsis_uninstall.py +++ b/tests/unit/desktop/test_nsis_uninstall.py @@ -135,6 +135,27 @@ def test_packaged_windows_entry_is_freeos_exe() -> None: assert "addsitedir" in launch or "site.addsitedir" in launch +def test_nsis_provisions_openxyos_runtime() -> None: + nsi = NSI.read_text(encoding="utf-8-sig") + nsh = NSH.read_text(encoding="utf-8") + assert "!insertmacro wails.provisionOpenXYOS" in nsh + assert 'File "/oname=openxyos-runtime.zip"' in nsh + assert r"$INSTDIR\openxyos" in nsh + assert r"$LOCALAPPDATA\FreeOS\openxyos" in nsh + assert "Expand-Archive" in nsh + assert "http://127.0.0.1:3780" in nsh + assert "LangString OPENXYOS_WORKDIR ${LANG_SIMPCHINESE}" in nsi + assert "创建 openXYOS 工作目录" in nsi + assert "openXYOS 运行包" in nsi + task = (REPO / "desktop" / "src" / "build" / "windows" / "Taskfile.yml").read_text( + encoding="utf-8" + ) + assert "stage:openxyos-runtime" in task + assert "stage_openxyos_runtime.py" in task + package = (REPO / "desktop" / "portable" / "package.sh").read_text(encoding="utf-8") + assert "openXYOS frontend missing" in package + + def test_desktop_readme_documents_uninstall_keep_vs_remove() -> None: text = DESKTOP_README.read_text(encoding="utf-8") assert "## Windows uninstall" in text @@ -148,3 +169,5 @@ def test_desktop_readme_documents_uninstall_keep_vs_remove() -> None: assert "Confirm" in text assert "## Windows install finish" in text assert "运行 FreeOS" in text + assert "%LOCALAPPDATA%\\FreeOS\\openxyos" in text + assert "127.0.0.1:3780" in text diff --git a/tests/unit/desktop/test_stage_openxyos_runtime.py b/tests/unit/desktop/test_stage_openxyos_runtime.py new file mode 100644 index 00000000..3bcf68c6 --- /dev/null +++ b/tests/unit/desktop/test_stage_openxyos_runtime.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import importlib.util +import zipfile +from pathlib import Path + +REPO = Path(__file__).resolve().parents[3] +_SPEC = importlib.util.spec_from_file_location( + "stage_openxyos_runtime", + REPO / "desktop" / "src" / "build" / "stage_openxyos_runtime.py", +) +assert _SPEC and _SPEC.loader +stage_mod = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(stage_mod) + + +def _write_bundle(root: Path) -> None: + node = root / "node" + node.mkdir(parents=True) + (node / "node.exe").write_text("node", encoding="utf-8") + app = root / "openxyos" + (app / "backend").mkdir(parents=True) + (app / "backend" / "server.ts").write_text("// server", encoding="utf-8") + (app / "dist").mkdir() + (app / "dist" / "index.html").write_text("", encoding="utf-8") + + +def test_stage_openxyos_runtime_from_sidecar_dir(tmp_path: Path) -> None: + src = tmp_path / "org-sidecar" + _write_bundle(src) + dest = tmp_path / "openxyos-runtime.zip" + stage_mod.stage(src, dest) + assert dest.is_file() + with zipfile.ZipFile(dest) as zf: + names = set(zf.namelist()) + assert "openxyos/dist/index.html" in names + assert "node/node.exe" in names + assert "openxyos/backend/server.ts" in names diff --git a/tests/unit/test_sidecar_launch.py b/tests/unit/test_sidecar_launch.py index 5bf730d8..e9eca30e 100644 --- a/tests/unit/test_sidecar_launch.py +++ b/tests/unit/test_sidecar_launch.py @@ -116,6 +116,20 @@ def test_ensure_sidecar_skips_source_tree_script( assert spawned == [] +def test_sidecar_bundle_dir_prefers_openxyos_home( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + work = tmp_path / "openxyos-home" + _write_runtime(work) + bundle = work / "org-sidecar" + monkeypatch.setenv("FREEOS_OPENXYOS_HOME", str(bundle)) + monkeypatch.delenv("OCTOP_GREEN_PACKAGES", raising=False) + monkeypatch.setenv("FREEOS_HOME", str(tmp_path / "unused-home")) + found = find_sidecar_runtime() + assert found is not None + assert found.root == bundle + + def test_sidecar_launch_env_writes_secrets(tmp_path: Path) -> None: env = sidecar_launch_env(tmp_path, dashboard_port=8099) assert env["DATABASE_PATH"] == str(tmp_path / "org-os" / "xiongyuan.db")