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 = (
+
+ }
+ onClick={startNewConversation}
+ >
+ {t("nav.newConversation")}
+
+ }
+ onClick={() => navigate("/projects?view=tasks&new=1")}
+ >
+ {t("nav.newTask")}
+
+ }
+ onClick={() => setOpen(true)}
+ >
+ {t("nav.newProject")}
+
+ }
+ onClick={() => setGroupOpen(true)}
+ >
+ {t("nav.newGroup")}
+
+
+ );
+
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" ? (
- }
- onClick={() => setOpen(true)}
- >
- {t("nav.newProject")}
-
- ) : null}
- {view === "conversations" ? (
- }
- onClick={() => navigate("/chat")}
- >
- {t("nav.newConversation")}
-
- ) : 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")