diff --git a/CHANGELOG.md b/CHANGELOG.md index 81b342f3..b2f0a118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,10 @@ ### 修复 +- 安装期不再在 livez 探测后 `taskkill` 边车:Setup 把 `start-sidecar.ps1` 写到 `%LOCALAPPDATA%\\FreeOS\\openxyos`,用当前用户(IShellDispatch2 / HKCU Run / 登录计划任务)常驻拉起 FE+BE,确认 `http://127.0.0.1:3780/api/health/livez` 后保持运行。组织页在 `.install-ready` 时自动嵌入本机 openXYOS,不再把「启动边车」当主按钮;仅自动启动失败才显示「重试启动」和中文原因。 +- 「下载最新 openXYOS 源码」端到端 UTF-8:Wails 文件夹选择器以 UTF-8 Base64 回传路径(不再把 GBK/ACP 当 UTF-8);落盘前修复 CP1252/Latin-1 误读的目标路径;解压按 zip UTF-8 标志或 GBK 还原中文目录名,避免乱码。 - Windows 知识库本地文件夹挂接:选择器与列表预览按 UTF-8 处理中文路径/文件名,避免 `项目…` 这类乱码;挂接与列表预览不再因向量模型未就绪返回 409。 + - 模型页注册本机 GGUF / 已拉取的 Ollama 模型时,Windows 桌面不再返回笼统的 `INTERNAL_ERROR` 500;`ollama create` 使用带引号的 POSIX `FROM` 路径,并把真实失败原因(文件不存在、权限、create 失败)返回给界面。 ### 变更 diff --git a/dashboard/src/api/modules/orgModule.ts b/dashboard/src/api/modules/orgModule.ts index eede7497..795c8e85 100644 --- a/dashboard/src/api/modules/orgModule.ts +++ b/dashboard/src/api/modules/orgModule.ts @@ -54,6 +54,7 @@ export interface OrgOverview { sidecar_reachable: boolean; sidecar_url: string; start_available: boolean; + install_ready: boolean; start_command: string; home: string; last_sync: string | null; diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 4eecdc2e..1a414aff 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -68,12 +68,15 @@ "timelineRemote": "Pushed to control plane", "timelineGovernance": "High-risk tool blocked by governance", "emptyTitle": "First look at Organization", - "emptyBody": "FreeOS on the left owns colleagues, experts / assistants not yet in a department, skills, MCP, and tasks. openXYOS on the right owns department employees, 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.", - "startSidecarAction": "Start sidecar", - "sidecarStarted": "Sidecar is reachable", - "sidecarStartPending": "Start attempted; refresh in a moment", + "emptyBody": "FreeOS on the left owns colleagues, experts / assistants not yet in a department, skills, MCP, and tasks. openXYOS on the right owns department employees, blueprints, modules, and approvals. After Setup the local console opens on its own; you can also run the loop once to see a real cycle.", + "startSidecarTitle": "openXYOS did not start automatically", + "startSidecarBody": "The install-time local console is not reachable. The reason is below; click Retry after you fix it.", + "startSidecarAction": "Retry start", + "sidecarStarted": "Local openXYOS is ready", + "sidecarStartPending": "Connecting to local openXYOS…", + "sidecarRecoverHint": "Only needed when auto-start fails. A healthy install should never ask you to start the sidecar.", + "sidecarAutoStarting": "Connecting the install-time local openXYOS…", + "sidecarAutoStartFailed": "Auto-start failed: {{detail}}", "bridgeHint": "FreeOS produces experts / assistants not yet in a department, plus skills, plugins, and MCPs. Those assets assemble into openXYOS as department employees; org blueprints and policies feed back as colleagues.", "home": "Data home", "sidecar": "Sidecar URL", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 635b5d3d..68356149 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -68,12 +68,15 @@ "timelineRemote": "已推送到控制面", "timelineGovernance": "高风险工具被治理拦截", "emptyTitle": "第一次打开组织", - "emptyBody": "左侧 FreeOS 负责同事、尚未入部门的专家 / 智能助手、技能、MCP 与任务;右侧 openXYOS 负责部门员工、蓝图、模块与审批。先启动边车,或直接运行组织循环看一次真实闭环。", - "startSidecarTitle": "openXYOS 边车未运行", - "startSidecarBody": "控制面离线时无法做 live 审批与远程回流。可一键启动本机边车;若启动器不在当前环境,请按旁边的命令手动启动。", - "startSidecarAction": "启动边车", - "sidecarStarted": "边车已可达", - "sidecarStartPending": "已尝试启动,请稍候再刷新", + "emptyBody": "左侧 FreeOS 负责同事、尚未入部门的专家 / 智能助手、技能、MCP 与任务;右侧 openXYOS 负责部门员工、蓝图、模块与审批。安装完成后会自动打开本机 openXYOS;也可直接运行组织循环看一次真实闭环。", + "startSidecarTitle": "openXYOS 未能自动启动", + "startSidecarBody": "安装期本机控制台没有连上。下面是具体原因;修好后点「重试启动」。", + "startSidecarAction": "重试启动", + "sidecarStarted": "本机 openXYOS 已就绪", + "sidecarStartPending": "正在自动连接本机 openXYOS…", + "sidecarRecoverHint": "仅在自动启动失败时需要处理。健康安装不应再点「启动边车」。", + "sidecarAutoStarting": "正在自动连接安装期本机 openXYOS…", + "sidecarAutoStartFailed": "自动启动失败:{{detail}}", "bridgeHint": "FreeOS 生产尚未入部门的专家 / 智能助手、技能、插件与 MCP;推入 openXYOS 后成为部门员工;组织蓝图与策略再回流,登记为本组织同事。", "home": "数据目录", "sidecar": "边车地址", diff --git a/dashboard/src/pages/Organization/index.tsx b/dashboard/src/pages/Organization/index.tsx index 5ce1a03f..feaa4faa 100644 --- a/dashboard/src/pages/Organization/index.tsx +++ b/dashboard/src/pages/Organization/index.tsx @@ -31,6 +31,7 @@ import { } from "../../utils/desktopFolder"; import { message } from "../../utils/antdMessage"; import { resolveOpenxyosSourceDest } from "./pickSourceDest"; +import { sidecarRecoverPhase } from "./sidecarRecover"; import styles from "./Organization.module.less"; type ActionKey = "assemble" | "pack" | "loop" | "sidecar" | "produce" | null; @@ -66,6 +67,8 @@ export default function OrganizationPage() { const [produceName, setProduceName] = useState(""); const [produceIma, setProduceIma] = useState(""); const [landed, setLanded] = useState | null>(null); + const [autoStartFailed, setAutoStartFailed] = useState(false); + const [recoverDetail, setRecoverDetail] = useState(""); const autoStartRef = useRef(false); const iframeRef = useRef(null); @@ -175,31 +178,49 @@ export default function OrganizationPage() { } }; - const startSidecar = () => + const startSidecar = (opts?: { silent?: boolean }) => runAction("sidecar", async () => { setPreviewPending(true); + setAutoStartFailed(false); try { const result = await orgModuleApi.startSidecar(); setLastActionNotes([result.detail, result.command].filter(Boolean)); const next = result.reachable ? await applyOverview(true) : await waitForSidecar(); - message.success( - next?.sidecar_reachable - ? t("organization.sidecarStarted") - : t("organization.sidecarStartPending"), - ); + if (next?.sidecar_reachable) { + setRecoverDetail(""); + if (!opts?.silent) { + message.success(t("organization.sidecarStarted")); + } + return; + } + const detail = result.detail || t("organization.sidecarStartPending"); + setAutoStartFailed(true); + setRecoverDetail(detail); + message.error(t("organization.sidecarAutoStartFailed", { detail })); + } catch (err) { + const detail = + err instanceof Error ? err.message : t("organization.actionFailed"); + setAutoStartFailed(true); + setRecoverDetail(detail); + message.error(t("organization.sidecarAutoStartFailed", { detail })); } finally { setPreviewPending(false); } }); + const canSilentStart = Boolean( + overview?.start_available || overview?.install_ready, + ); + useEffect(() => { if (autoStartRef.current) return; - if (!overview?.start_available || overview.sidecar_reachable) return; + if (!overview || overview.sidecar_reachable) return; + if (!canSilentStart) return; autoStartRef.current = true; - void startSidecar(); - }, [overview?.start_available, overview?.sidecar_reachable]); + void startSidecar({ silent: true }); + }, [canSilentStart, overview, overview?.sidecar_reachable]); const toggleModule = async (key: string, enabled: boolean) => { const next = { ...moduleToggles, [key]: enabled }; @@ -384,6 +405,13 @@ export default function OrganizationPage() { )}`; }, [overview?.sidecar_url, disabledKeys]); const showFrame = Boolean(sidecarUp && previewUrl); + const recoverPhase = sidecarRecoverPhase({ + sidecarUp, + installReady: Boolean(overview?.install_ready), + startAvailable: Boolean(overview?.start_available), + autoStarting: previewPending || busy === "sidecar", + autoStartFailed, + }); const pushTogglesToPreview = useCallback(() => { const frame = iframeRef.current?.contentWindow; @@ -459,7 +487,18 @@ export default function OrganizationPage() { /> - {!sidecarUp && ( + {recoverPhase === "starting" && !sidecarUp && ( +
+

+ {t("organization.sidecarAutoStarting")} +

+

+ {t("organization.sidecarStartPending")} +

+
+ )} + + {recoverPhase === "recover" && (

{t("organization.startSidecarTitle")} @@ -467,9 +506,14 @@ export default function OrganizationPage() {

{t("organization.startSidecarBody")}

+ {recoverDetail ? ( +

{recoverDetail}

+ ) : null} +

+ {t("organization.sidecarRecoverHint")} +

- {overview?.start_command && ( - {overview.start_command} - )} - + {recoverPhase === "recover" && ( + + + {overview?.start_command && ( + {overview.start_command} + )} + + )} )} diff --git a/dashboard/src/pages/Organization/sidecarRecover.test.ts b/dashboard/src/pages/Organization/sidecarRecover.test.ts new file mode 100644 index 00000000..b42eedc2 --- /dev/null +++ b/dashboard/src/pages/Organization/sidecarRecover.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { sidecarRecoverPhase } from "./sidecarRecover"; + +describe("sidecarRecoverPhase", () => { + it("hides recover when the sidecar is already up", () => { + expect( + sidecarRecoverPhase({ + sidecarUp: true, + installReady: true, + startAvailable: true, + autoStarting: false, + autoStartFailed: false, + }), + ).toBe("hidden"); + }); + + it("auto-starts silently after a healthy install instead of showing 启动边车", () => { + expect( + sidecarRecoverPhase({ + sidecarUp: false, + installReady: true, + startAvailable: true, + autoStarting: false, + autoStartFailed: false, + }), + ).toBe("starting"); + }); + + it("shows recover only after auto-start fails", () => { + expect( + sidecarRecoverPhase({ + sidecarUp: false, + installReady: true, + startAvailable: true, + autoStarting: false, + autoStartFailed: true, + }), + ).toBe("recover"); + }); + + it("shows recover when there is no install-time runtime to start", () => { + expect( + sidecarRecoverPhase({ + sidecarUp: false, + installReady: false, + startAvailable: false, + autoStarting: false, + autoStartFailed: false, + }), + ).toBe("recover"); + }); +}); diff --git a/dashboard/src/pages/Organization/sidecarRecover.ts b/dashboard/src/pages/Organization/sidecarRecover.ts new file mode 100644 index 00000000..e4bfc005 --- /dev/null +++ b/dashboard/src/pages/Organization/sidecarRecover.ts @@ -0,0 +1,15 @@ +export type SidecarRecoverPhase = "hidden" | "starting" | "recover"; + +export function sidecarRecoverPhase(input: { + sidecarUp: boolean; + installReady: boolean; + startAvailable: boolean; + autoStarting: boolean; + autoStartFailed: boolean; +}): SidecarRecoverPhase { + if (input.sidecarUp) return "hidden"; + if (input.autoStarting) return "starting"; + if (input.autoStartFailed) return "recover"; + if (input.installReady || input.startAvailable) return "starting"; + return "recover"; +} diff --git a/dashboard/src/utils/utf8Mojibake.ts b/dashboard/src/utils/utf8Mojibake.ts index da51d1f3..71fc69d9 100644 --- a/dashboard/src/utils/utf8Mojibake.ts +++ b/dashboard/src/utils/utf8Mojibake.ts @@ -37,6 +37,7 @@ function cp1252Byte(code: number): number | null { /** * Repair UTF-8 text that a Windows bridge exposed as Latin-1 / CP1252. * Example: ``项目结构.png`` → ``项目结构.png``. + */ export function repairUtf8Mojibake(text: string): string { if (!text) return text; diff --git a/desktop/README.md b/desktop/README.md index af16623e..b4a1f66b 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -28,7 +28,10 @@ Same precedence as the FreeOS CLI/server: (and keeps `$INSTDIR\openxyos` / `$INSTDIR\openxyos-runtime` as backups). Install fails if `node` or `dist` is missing, or if `http://127.0.0.1:3780/api/health/livez` does not pass before Setup exits. - First FreeOS start only spawns the already-on-disk sidecar — no copy/unpack. + Setup then leaves a **user-level** sidecar running (HKCU Run + `FreeOS-openXYOS` + `start-sidecar.ps1`) so Organization embeds immediately + — no first-open copy and no 「启动边车」 click. First FreeOS start only + attaches to that live tree if the port is already healthy. - Shell prefs → `{home}/desktop-settings.json` ## Windows install finish @@ -46,8 +49,12 @@ and extracts it with Windows `tar.exe` (quoted paths, so `Program Files` works) into the **live** workdir `%LOCALAPPDATA%\FreeOS\openxyos` during Setup. `$INSTDIR\openxyos` and `$INSTDIR\openxyos-runtime` are sealed backups only. Setup **aborts** if `node\node.exe` or `dist\index.html` is -missing, or if an install-time sidecar probe cannot reach +missing, or if the user-level sidecar cannot reach `/api/health/livez` — a README-only tree is not a successful install. +The probe **does not** start Node as Administrator and **does not** +`taskkill` it afterwards (that left Organization asking to 启动边车). +PersistOpenXYOS launches `start-sidecar.ps1` with the explorer token, +registers HKCU Run `FreeOS-openXYOS`, and leaves the process up. Why LocalAppData, written by an elevated installer: `$INSTDIR` is typically `C:\Program Files\FreeOS`, which a later unelevated `FreeOS.exe` cannot @@ -78,7 +85,8 @@ rather than cutting a new tag. created next to the exe, extracted portable / org-sidecar leftovers if they were written under `$INSTDIR`, and any other installer-owned tree there - Start Menu, Desktop, and Startup shortcuts created by the installer -- Add/Remove Programs registry key and the autostart Run value +- Add/Remove Programs registry key and the autostart Run values + (`FreeOS` / `FreeOS-openXYOS`) plus the `FreeOS-openXYOS` logon task - Program-owned WebView2 / Wails cache under `%AppData%\FreeOS.exe`, `%LOCALAPPDATA%\FreeOS.exe.WebView2`, and `%LOCALAPPDATA%\FreeOS` (including `WebView2\`) @@ -104,8 +112,10 @@ On first open the shell: 2. If that live workdir is already complete, skips any copy/unpack. A copy from `$INSTDIR` / `portable/org-sidecar` is heal-only (other Windows users, or a tree that Setup never populated). -3. Starts the openXYOS sidecar on `http://127.0.0.1:3780` from the live - workdir and waits until `/api/health/livez` responds. +3. If `http://127.0.0.1:3780/api/health/livez` is already up (install-time + persist / HKCU Run), attaches to it. Otherwise starts the sidecar from + the live workdir and waits until livez responds. Closing FreeOS does + **not** kill that user-level sidecar. 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 diff --git a/desktop/src/build/windows/nsis/project.nsi b/desktop/src/build/windows/nsis/project.nsi index f330efdb..58f3aa66 100644 --- a/desktop/src/build/windows/nsis/project.nsi +++ b/desktop/src/build/windows/nsis/project.nsi @@ -68,10 +68,12 @@ LangString OPENXYOS_EXTRACT_CODE ${LANG_SIMPCHINESE} "解压结束,退出码 " LangString OPENXYOS_EXTRACT_CODE ${LANG_ENGLISH} "Extract finished, exit code " LangString OPENXYOS_EXTRACT_FAIL ${LANG_SIMPCHINESE} "安装失败:未能把完整的 openXYOS 运行环境解压到 %LOCALAPPDATA%\\FreeOS\\openxyos(需要 node\\node.exe 与 dist\\index.html)。请重新下载安装包。" LangString OPENXYOS_EXTRACT_FAIL ${LANG_ENGLISH} "Setup failed: could not extract a complete openXYOS runtime into %LOCALAPPDATA%\\FreeOS\\openxyos (need node\\node.exe and dist\\index.html). Download the installer again." -LangString OPENXYOS_PROBE ${LANG_SIMPCHINESE} "正在启动本机 openXYOS 并检查 http://127.0.0.1:3780/api/health/livez" -LangString OPENXYOS_PROBE ${LANG_ENGLISH} "Starting local openXYOS and checking http://127.0.0.1:3780/api/health/livez" -LangString OPENXYOS_PROBE_OK ${LANG_SIMPCHINESE} "openXYOS 已通过安装期健康检查,首次打开无需再解压" -LangString OPENXYOS_PROBE_OK ${LANG_ENGLISH} "openXYOS passed the install-time health check; first launch will not unpack" +LangString OPENXYOS_AUTOSTART ${LANG_SIMPCHINESE} "写入本机 openXYOS 常驻启动脚本(登录后自动拉起,无需「启动边车」)" +LangString OPENXYOS_AUTOSTART ${LANG_ENGLISH} "Write the persistent openXYOS start helper (logon auto-start; no Start sidecar click)" +LangString OPENXYOS_PROBE ${LANG_SIMPCHINESE} "正在以当前用户启动本机 openXYOS 并检查 http://127.0.0.1:3780/api/health/livez" +LangString OPENXYOS_PROBE ${LANG_ENGLISH} "Starting local openXYOS as the current user and checking http://127.0.0.1:3780/api/health/livez" +LangString OPENXYOS_PROBE_OK ${LANG_SIMPCHINESE} "openXYOS 已在本机运行,并已登记用户级开机自启" +LangString OPENXYOS_PROBE_OK ${LANG_ENGLISH} "openXYOS is running locally and registered for user-level auto-start" LangString OPENXYOS_PROBE_FAIL ${LANG_SIMPCHINESE} "安装失败:已解压 openXYOS,但 http://127.0.0.1:3780/api/health/livez 未就绪。请检查 3780 端口占用后重试安装。" LangString OPENXYOS_PROBE_FAIL ${LANG_ENGLISH} "Setup failed: openXYOS is on disk but http://127.0.0.1:3780/api/health/livez did not become ready. Free port 3780 and run Setup again." LangString UN_FREEOS_RUNNING ${LANG_SIMPCHINESE} "检测到 FreeOS 仍在运行(主程序、主机或组织侧车)。$\r$\n$\r$\n继续将结束这些进程,并删除安装目录中的程序文件。$\r$\n用户数据(%USERPROFILE%\.freeos)会保留。$\r$\n$\r$\n要继续卸载吗?" @@ -142,6 +144,9 @@ Section "uninstall" SetRegView 64 DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${INFO_PRODUCTNAME}" DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${INFO_PROJECTNAME}" + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "FreeOS-openXYOS" + nsExec::ExecToLog 'schtasks.exe /Delete /TN "FreeOS-openXYOS" /F' + Pop $0 !insertmacro wails.unassociateFiles !insertmacro wails.unassociateCustomProtocols @@ -150,6 +155,28 @@ Section "uninstall" !insertmacro wails.wipeInstallDir SectionEnd +# Launch the live openXYOS helper as the unelevated user and keep it. +# An admin CreateProcess would High-integrity ~/.freeos; #29 then killed +# the probe process, so Organization still asked to 启动边车. +Function PersistOpenXYOS + !insertmacro wails.userLocalAppData + SetRegView 64 + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "FreeOS-openXYOS" '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "$R6\FreeOS\openxyos\start-sidecar.ps1"' + nsExec::ExecToLog 'schtasks.exe /Create /TN "FreeOS-openXYOS" /SC ONLOGON /RL LIMITED /F /TR "\"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe\" -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \"$R6\FreeOS\openxyos\start-sidecar.ps1\""' + Pop $0 + System::Call "ole32::CoInitialize(i 0)" + System::Call 'ole32::CoCreateInstance(g "{13709620-C279-11CE-A49E-444553540000}",i 0,i 1,g "{A4C6892C-3BA9-11d2-9DEA-00C04FB16162}",*i .r0) i .r1' + ${If} $1 == 0 + ${AndIf} $0 != 0 + System::Call '$0->31(w "$SYSDIR\WindowsPowerShell\v1.0\powershell.exe", w "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File $\"$R6\FreeOS\openxyos\start-sidecar.ps1\"", w "$R6\FreeOS\openxyos", w "open", i 0)' + System::Call "$0->2()" + ${Else} + nsExec::ExecToLog 'schtasks.exe /Run /TN "FreeOS-openXYOS"' + Pop $0 + Exec '"$WINDIR\explorer.exe" "$R6\FreeOS\openxyos\start-sidecar.cmd"' + ${EndIf} +FunctionEnd + Function LaunchFreeOS SetOutPath "$INSTDIR" ; Installer is admin. Exec/CreateProcess inherits that token and the diff --git a/desktop/src/build/windows/nsis/wails_tools.nsh b/desktop/src/build/windows/nsis/wails_tools.nsh index 9b21f741..8d91c051 100644 --- a/desktop/src/build/windows/nsis/wails_tools.nsh +++ b/desktop/src/build/windows/nsis/wails_tools.nsh @@ -206,10 +206,13 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" FileWrite $0 "This folder is a backup. FreeOS starts the live tree.$\r$\n" FileWrite $0 "URL: http://127.0.0.1:3780$\r$\n" FileClose $0 + !insertmacro wails.writeOpenXYOSAutostart + Call PersistOpenXYOS !insertmacro wails.probeOpenXYOS FileOpen $0 "$R6\FreeOS\openxyos\.install-ready" w FileWrite $0 "live=$R6\FreeOS\openxyos$\r$\n" FileWrite $0 "url=http://127.0.0.1:3780/api/health/livez$\r$\n" + FileWrite $0 "autostart=hkcu-run:FreeOS-openXYOS$\r$\n" FileClose $0 SetDetailsPrint listonly !macroend @@ -260,40 +263,75 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" openxyosLayoutDone: !macroend -# Start the install-time live tree long enough for /api/health/livez, then -# stop it. The probe uses a throwaway sqlite under $PLUGINSDIR so Setup -# does not stamp ~/.freeos as High integrity. Paths are expanded into a -# .ps1 (no NSIS $INSTDIR on a -Command line). -!macro wails.probeOpenXYOS - DetailPrint "$(OPENXYOS_PROBE)" - FileOpen $0 "$PLUGINSDIR\probe-openxyos.ps1" w +# User-level start helper for the live LocalAppData tree. The elevated +# installer must not spawn Node itself: that would stamp ~/.freeos as High +# integrity and #29 then taskkill'd the probe, leaving Organization offline. +# PersistOpenXYOS (project.nsi) registers HKCU Run and launches this script +# via IShellDispatch2 (medium IL). Probe only waits for livez — no kill. +!macro wails.writeOpenXYOSAutostart + DetailPrint "$(OPENXYOS_AUTOSTART)" + FileOpen $0 "$R6\FreeOS\openxyos\start-sidecar.cmd" w + FileWrite $0 `@echo off$\r$\n` + FileWrite $0 `setlocal EnableExtensions$\r$\n` + FileWrite $0 `powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "%~dp0start-sidecar.ps1"$\r$\n` + FileClose $0 + FileOpen $0 "$R6\FreeOS\openxyos\start-sidecar.ps1" w FileWrite $0 `$$ErrorActionPreference = 'Continue'$\r$\n` - FileWrite $0 `$$node = '$R6\FreeOS\openxyos\node\node.exe'$\r$\n` - FileWrite $0 `$$app = '$R6\FreeOS\openxyos\openxyos'$\r$\n` - FileWrite $0 `if (-not (Test-Path -LiteralPath $$app)) { $$app = '$R6\FreeOS\openxyos' }$\r$\n` + FileWrite $0 `$$live = Split-Path -Parent $$MyInvocation.MyCommand.Path$\r$\n` + FileWrite $0 `$$node = Join-Path $$live 'node\node.exe'$\r$\n` + FileWrite $0 `$$app = Join-Path $$live 'openxyos'$\r$\n` + FileWrite $0 `if (-not (Test-Path -LiteralPath (Join-Path $$app 'dist\index.html'))) { if (Test-Path -LiteralPath (Join-Path $$live 'dist\index.html')) { $$app = $$live } }$\r$\n` FileWrite $0 `$$livez = 'http://127.0.0.1:3780/api/health/livez'$\r$\n` FileWrite $0 `function Test-Livez { try { $$r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 -Uri $$livez; return ($$r.StatusCode -lt 500) } catch { return $$false } }$\r$\n` FileWrite $0 `if (Test-Livez) { exit 0 }$\r$\n` + FileWrite $0 `if (-not (Test-Path -LiteralPath $$node)) { exit 6 }$\r$\n` + FileWrite $0 `$$home = $$env:FREEOS_HOME$\r$\n` + FileWrite $0 `if (-not $$home) { $$home = Join-Path $$env:USERPROFILE '.freeos' }$\r$\n` + FileWrite $0 `$$data = Join-Path $$home 'org-os'$\r$\n` + FileWrite $0 `New-Item -ItemType Directory -Force -Path $$data | Out-Null$\r$\n` + FileWrite $0 `$$secretFile = Join-Path $$data 'sidecar.env'$\r$\n` + FileWrite $0 `$$jwt = ''$\r$\n` + FileWrite $0 `$$cookie = ''$\r$\n` + FileWrite $0 `$$ingest = ''$\r$\n` + FileWrite $0 `if (Test-Path -LiteralPath $$secretFile) { foreach ($$line in Get-Content -LiteralPath $$secretFile) { if ($$line -match '^JWT_SECRET=(.+)$$') { $$jwt = $$Matches[1] }; if ($$line -match '^COOKIE_SECRET=(.+)$$') { $$cookie = $$Matches[1] }; if ($$line -match '^FREEOS_INGEST_TOKEN=(.+)$$') { $$ingest = $$Matches[1] } } }$\r$\n` + FileWrite $0 `if (-not $$jwt) { $$jwt = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N') }$\r$\n` + FileWrite $0 `if (-not $$cookie) { $$cookie = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N') }$\r$\n` + FileWrite $0 `if (-not $$ingest) { $$ingest = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N') }$\r$\n` + FileWrite $0 `"JWT_SECRET=$$jwt" | Set-Content -LiteralPath $$secretFile -Encoding ASCII$\r$\n` + FileWrite $0 `"COOKIE_SECRET=$$cookie" | Add-Content -LiteralPath $$secretFile -Encoding ASCII$\r$\n` + FileWrite $0 `"FREEOS_INGEST_TOKEN=$$ingest" | Add-Content -LiteralPath $$secretFile -Encoding ASCII$\r$\n` FileWrite $0 `$$env:NODE_ENV = 'production'$\r$\n` FileWrite $0 `$$env:PORT = '3780'$\r$\n` FileWrite $0 `$$env:DB_DIALECT = 'sqlite'$\r$\n` - FileWrite $0 `$$env:DATABASE_PATH = '$PLUGINSDIR\openxyos-probe.db'$\r$\n` + FileWrite $0 `$$env:DATABASE_PATH = Join-Path $$data 'xiongyuan.db'$\r$\n` FileWrite $0 `$$env:AIR_GAP_MODE = 'true'$\r$\n` FileWrite $0 `$$env:SEED_DEMO_DATA = 'false'$\r$\n` FileWrite $0 `$$env:ALLOW_PUBLIC_REGISTRATION = 'false'$\r$\n` - FileWrite $0 `$$env:JWT_SECRET = '0123456789abcdef0123456789abcdef'$\r$\n` - FileWrite $0 `$$env:COOKIE_SECRET = 'fedcba9876543210fedcba9876543210'$\r$\n` - FileWrite $0 `$$env:CORS_ORIGIN = 'http://127.0.0.1:8088'$\r$\n` + FileWrite $0 `$$env:JWT_SECRET = $$jwt$\r$\n` + FileWrite $0 `$$env:COOKIE_SECRET = $$cookie$\r$\n` + FileWrite $0 `$$env:FREEOS_INGEST_TOKEN = $$ingest$\r$\n` + FileWrite $0 `$$env:CORS_ORIGIN = 'http://127.0.0.1:8088,http://localhost:8088,http://127.0.0.1:18900,http://localhost:18900'$\r$\n` + FileWrite $0 `$$env:FREEOS_HOME = $$home$\r$\n` + FileWrite $0 `$$env:OCTOP_HOME = $$home$\r$\n` + FileWrite $0 `$$env:FREEOS_ORG_SIDECAR_PORT = '3780'$\r$\n` FileWrite $0 `$$compiled = Join-Path $$app 'backend-dist\server.js'$\r$\n` FileWrite $0 `if (Test-Path -LiteralPath $$compiled) { $$argv = @('backend-dist/server.js') } else { $$argv = @('--import'; 'tsx'; 'backend/server.ts') }$\r$\n` - FileWrite $0 `$$p = Start-Process -FilePath $$node -ArgumentList $$argv -WorkingDirectory $$app -WindowStyle Hidden -PassThru$\r$\n` - FileWrite $0 `$$ok = $$false$\r$\n` - FileWrite $0 `for ($$i = 0; $$i -lt 60; $$i++) { Start-Sleep -Seconds 1; if (Test-Livez) { $$ok = $$true; break } }$\r$\n` - FileWrite $0 `if ($$p -and -not $$p.HasExited) { & "$$env:SystemRoot\System32\taskkill.exe" /F /T /PID $$p.Id | Out-Null }$\r$\n` - FileWrite $0 `if ($$ok) { exit 0 }$\r$\n` + FileWrite $0 `Start-Process -FilePath $$node -ArgumentList $$argv -WorkingDirectory $$app -WindowStyle Hidden$\r$\n` + FileWrite $0 `exit 0$\r$\n` + FileClose $0 +!macroend + +# Wait for the user-level sidecar PersistOpenXYOS launched. Do not spawn +# Node as admin and do not kill the probe process — that was the #29 gap. +!macro wails.probeOpenXYOS + DetailPrint "$(OPENXYOS_PROBE)" + FileOpen $0 "$PLUGINSDIR\wait-openxyos.ps1" w + FileWrite $0 `$$livez = 'http://127.0.0.1:3780/api/health/livez'$\r$\n` + FileWrite $0 `function Test-Livez { try { $$r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 -Uri $$livez; return ($$r.StatusCode -lt 500) } catch { return $$false } }$\r$\n` + FileWrite $0 `for ($$i = 0; $$i -lt 60; $$i++) { if (Test-Livez) { exit 0 }; Start-Sleep -Seconds 1 }$\r$\n` FileWrite $0 `exit 12$\r$\n` FileClose $0 - nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "$PLUGINSDIR\probe-openxyos.ps1"' + nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "$PLUGINSDIR\wait-openxyos.ps1"' Pop $0 DetailPrint "$(OPENXYOS_EXTRACT_CODE)$0" IntCmp $0 0 openxyosProbeOk openxyosProbeFail openxyosProbeFail @@ -437,7 +475,7 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" # (python + launch.py run), and org-sidecar node. $R7 is "1" when any match. !macro wails.detectFreeOSProcesses StrCpy $R7 "0" - nsExec::Exec 'powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$$ErrorActionPreference=''SilentlyContinue''; $$n = @(Get-CimInstance Win32_Process | Where-Object { ($$_.Name -eq ''${PRODUCT_EXECUTABLE}'') -or ($$_.ExecutablePath -like ''$INSTDIR*'') -or ($$_.CommandLine -like ''*\portable\*launch.py* run*'') -or ($$_.ExecutablePath -like ''*\org-sidecar\*'') }).Count; if ($$n -gt 0) { exit 11 } else { exit 0 }"' + nsExec::Exec 'powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$$ErrorActionPreference=''SilentlyContinue''; $$n = @(Get-CimInstance Win32_Process | Where-Object { ($$_.Name -eq ''${PRODUCT_EXECUTABLE}'') -or ($$_.ExecutablePath -like ''$INSTDIR*'') -or ($$_.CommandLine -like ''*\portable\*launch.py* run*'') -or ($$_.ExecutablePath -like ''*\org-sidecar\*'') -or ($$_.ExecutablePath -like ''*\FreeOS\openxyos\*'') }).Count; if ($$n -gt 0) { exit 11 } else { exit 0 }"' Pop $0 ${If} $0 == 11 StrCpy $R7 "1" @@ -469,7 +507,7 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" DetailPrint "Stopping FreeOS processes..." nsExec::ExecToLog 'taskkill /T /IM "${PRODUCT_EXECUTABLE}"' Pop $0 - nsExec::ExecToLog 'powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$$ErrorActionPreference=''SilentlyContinue''; $$sel = { ($$_.Name -eq ''${PRODUCT_EXECUTABLE}'') -or ($$_.ExecutablePath -like ''$INSTDIR*'') -or ($$_.CommandLine -like ''*\portable\*launch.py* run*'') -or ($$_.ExecutablePath -like ''*\org-sidecar\*'') }; Get-CimInstance Win32_Process | Where-Object $$sel | ForEach-Object { try { $$p = Get-Process -Id $$_.ProcessId; [void]$$p.CloseMainWindow() } catch {} }; Start-Sleep -Seconds 2; Get-CimInstance Win32_Process | Where-Object $$sel | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force }"' + nsExec::ExecToLog 'powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$$ErrorActionPreference=''SilentlyContinue''; $$sel = { ($$_.Name -eq ''${PRODUCT_EXECUTABLE}'') -or ($$_.ExecutablePath -like ''$INSTDIR*'') -or ($$_.CommandLine -like ''*\portable\*launch.py* run*'') -or ($$_.ExecutablePath -like ''*\org-sidecar\*'') -or ($$_.ExecutablePath -like ''*\FreeOS\openxyos\*'') }; Get-CimInstance Win32_Process | Where-Object $$sel | ForEach-Object { try { $$p = Get-Process -Id $$_.ProcessId; [void]$$p.CloseMainWindow() } catch {} }; Start-Sleep -Seconds 2; Get-CimInstance Win32_Process | Where-Object $$sel | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force }"' Pop $0 nsExec::ExecToLog 'taskkill /F /T /IM "${PRODUCT_EXECUTABLE}"' Pop $0 diff --git a/desktop/src/folder_dialog.go b/desktop/src/folder_dialog.go index 41aef96c..df272898 100644 --- a/desktop/src/folder_dialog.go +++ b/desktop/src/folder_dialog.go @@ -43,11 +43,12 @@ func pickFolderNative() (string, error) { } } -func pickFolderWindows() (string, error) { - // FolderBrowserDialog with MyComputer so every drive letter is reachable. - // SelectedPath is a .NET Unicode string; emit UTF-8 as Base64 so the - // hidden-window PowerShell console code page cannot mojibake CJK. - script := strings.Join( +// folderPickerWindowsScript prints the selected path as UTF-8 base64. +// PowerShell's default stdout is the OEM/ACP code page (GBK on zh-CN +// Windows). Interpreting those bytes as UTF-8 garbles Chinese folder names. +func folderPickerWindowsScript() string { + return strings.Join( + []string{ "Add-Type -AssemblyName System.Windows.Forms", "[void][System.Windows.Forms.Application]::EnableVisualStyles()", @@ -61,13 +62,16 @@ func pickFolderWindows() (string, error) { }, "; ", ) +} + +func pickFolderWindows() (string, error) { cmd := exec.Command( "powershell.exe", "-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-Command", - script, + folderPickerWindowsScript(), ) hideConsole(cmd) out, err := cmd.Output() diff --git a/desktop/src/folder_dialog_test.go b/desktop/src/folder_dialog_test.go index 797c9488..ad0936a6 100644 --- a/desktop/src/folder_dialog_test.go +++ b/desktop/src/folder_dialog_test.go @@ -2,9 +2,21 @@ package main import ( "encoding/base64" + "strings" "testing" ) +func TestFolderPickerWindowsScriptUsesUTF8Base64(t *testing.T) { + script := folderPickerWindowsScript() + if !strings.Contains(script, "[System.Text.Encoding]::UTF8.GetBytes") { + t.Fatal("Windows folder picker must emit UTF-8 bytes, not ACP/GBK stdout") + } + if !strings.Contains(script, "[Convert]::ToBase64String") { + t.Fatal("Windows folder picker must base64 the UTF-8 path") + } +} + + func TestDecodeFolderPickerOutputPrefersUTF8Base64(t *testing.T) { path := `D:\项目\资料` encoded := base64.StdEncoding.EncodeToString([]byte(path)) @@ -34,8 +46,18 @@ func TestDecodeFolderPickerOutputReadsUTF16LE(t *testing.T) { } } +func TestDecodeFolderPickerOutputEmpty(t *testing.T) { + got, err := decodeFolderPickerOutput([]byte("\r\n")) + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Fatalf("empty picker should be %q, got %q", "", got) + } +} func TestRepairUTF8MojibakeChineseFilename(t *testing.T) { // UTF-8 of 项目结构.png interpreted as Windows-1252. + mojibake := string([]rune{ 0x00E9, 0x00A1, 0x00B9, 0x00E7, 0x009B, 0x00AE, diff --git a/desktop/src/main.go b/desktop/src/main.go index 01488064..a2e6b1d5 100644 --- a/desktop/src/main.go +++ b/desktop/src/main.go @@ -49,12 +49,13 @@ func (a *App) ServiceShutdown() error { a.sleep.stop() a.mu.Lock() cmd := a.cmd - sidecar := a.sidecar a.cmd = nil a.sidecar = nil a.mu.Unlock() stopOctop(cmd) - stopOctop(sidecar) + // Leave the user-level openXYOS sidecar running. Install registers + // HKCU Run / start-sidecar.ps1 so Organization embeds without a + // 「启动边车」 click. Killing it here recreated the #29 first-open gap. return nil } diff --git a/desktop/src/process_sidecar.go b/desktop/src/process_sidecar.go index 9e6b9b59..13ec37fe 100644 --- a/desktop/src/process_sidecar.go +++ b/desktop/src/process_sidecar.go @@ -156,9 +156,28 @@ func resolveSidecarDir(portableRoot string) string { return "" } +// sidecarHealthURLOverride is a test seam for sidecarLive / waitSidecarLive. +var sidecarHealthURLOverride string + +func sidecarHealthCheckURL() string { + if sidecarHealthURLOverride != "" { + return sidecarHealthURLOverride + } + return sidecarURL() + "/api/health/livez" +} + +func sidecarLive() bool { + resp, err := http.Get(sidecarHealthCheckURL()) + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode < 500 +} + func waitSidecarLive(timeout time.Duration) error { deadline := time.Now().Add(timeout) - url := sidecarURL() + "/api/health/livez" + url := sidecarHealthCheckURL() var last error for time.Now().Before(deadline) { resp, err := http.Get(url) @@ -264,6 +283,10 @@ func sidecarLaunchEnv(home string, dashboardPort int) (map[string]string, error) } func startOrgSidecar(root string, dashboardPort int) (*exec.Cmd, error) { + if sidecarLive() { + log.Printf("organization sidecar already live at %s", sidecarURL()) + return nil, nil + } bundle := resolveSidecarDir(root) if bundle == "" { return nil, nil diff --git a/desktop/src/process_sidecar_test.go b/desktop/src/process_sidecar_test.go index 047edcba..42a2e4b6 100644 --- a/desktop/src/process_sidecar_test.go +++ b/desktop/src/process_sidecar_test.go @@ -3,6 +3,8 @@ package main import ( "archive/zip" "io" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -276,6 +278,39 @@ func zipDir(src, dest string) error { return w.Close() } +func TestSidecarLiveReadsHealthURL(t *testing.T) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/health/livez" { + t.Fatalf("path %s", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + t.Cleanup(srv.Close) + sidecarHealthURLOverride = srv.URL + "/api/health/livez" + t.Cleanup(func() { sidecarHealthURLOverride = "" }) + if !sidecarLive() { + t.Fatal("healthy livez should count as live") + } +} + +func TestStartOrgSidecarSkipsWhenLive(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + sidecarHealthURLOverride = srv.URL + "/api/health/livez" + t.Cleanup(func() { sidecarHealthURLOverride = "" }) + cmd, err := startOrgSidecar(t.TempDir(), 8088) + if err != nil { + t.Fatal(err) + } + if cmd != nil { + t.Fatal("already-live sidecar must not spawn a second node") + } +} + func TestSidecarNodeArgsPrefersCompiledServer(t *testing.T) { app := t.TempDir() args := sidecarNodeArgs(app) diff --git a/src/octop/api/routers/org_module.py b/src/octop/api/routers/org_module.py index 9db1d0ee..c09786e5 100644 --- a/src/octop/api/routers/org_module.py +++ b/src/octop/api/routers/org_module.py @@ -25,6 +25,7 @@ from octop.modules.org_os.service import OrgModuleService, org_module_from_paths from octop.modules.org_os.sidecar_launch import ( sidecar_can_start, + sidecar_install_ready, sidecar_start_command, start_sidecar, ) @@ -106,6 +107,7 @@ async def org_module_overview( cron_jobs=counts["cron_jobs"], skill_packages=counts["skill_packages"], start_available=sidecar_can_start(), + install_ready=sidecar_install_ready(), ) payload = snapshot.to_dict() payload["start_command"] = sidecar_start_command() diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index c980ce1f..e1cadd18 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -724,6 +724,7 @@ "hero": "FreeOS does the work. openXYOS owns organization and governance.", "planes": "Data plane: colleagues / experts / skills / MCP / tasks. Control plane: department employees / blueprints / modules / governance.", "sidecar_offline": "openXYOS sidecar is offline — start it to sync blueprints and approvals.", + "sidecar_reconnecting": "openXYOS sidecar is offline — reconnecting the install-time local console.", "mirror_only": "OPENXYOS_BASE_URL unset; applied to local mirror only.", "local_mirror_short": "Local mirror is the durable record.", "imported_export": "imported live control-plane export", diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index 48c8a788..a8c5d1e7 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -724,6 +724,7 @@ "hero": "FreeOS 负责干活,openXYOS 负责组织与治理。", "planes": "数据面:同事 / 专家 / 技能 / MCP / 任务。控制面:部门员工 / 蓝图 / 模块 / 治理。", "sidecar_offline": "openXYOS 边车离线 — 启动后才能同步蓝图与审批。", + "sidecar_reconnecting": "openXYOS 边车离线 — 正在自动连接安装期本机控制台。", "mirror_only": "未设置 OPENXYOS_BASE_URL;仅写入本地镜像。", "local_mirror_short": "本地镜像是可靠记录。", "imported_export": "已导入在线控制面导出", diff --git a/src/octop/infra/server.py b/src/octop/infra/server.py index 9094dbd3..b9240d17 100644 --- a/src/octop/infra/server.py +++ b/src/octop/infra/server.py @@ -600,7 +600,7 @@ def _ensure_desktop_org_sidecar(self) -> None: from octop.modules.org_os.sidecar_launch import ensure_sidecar service = org_module_from_paths(self.paths) - result = ensure_sidecar(service, wait=8.0) + result = ensure_sidecar(service) logger.info( "organization sidecar ensure started=%s already=%s reachable=%s detail=%s", result.started, diff --git a/src/octop/modules/org_os/notes.py b/src/octop/modules/org_os/notes.py index 82d35e3b..a955b491 100644 --- a/src/octop/modules/org_os/notes.py +++ b/src/octop/modules/org_os/notes.py @@ -99,6 +99,13 @@ "org.notes.sidecar_offline", (), ), + ( + re.compile( + r"^openXYOS sidecar is offline — reconnecting the install-time local console\.$" + ), + "org.notes.sidecar_reconnecting", + (), + ), ( re.compile(r"^OPENXYOS_BASE_URL unset; applied to local mirror only$"), "org.notes.mirror_only", diff --git a/src/octop/modules/org_os/overview.py b/src/octop/modules/org_os/overview.py index 98c77a8c..30873a96 100644 --- a/src/octop/modules/org_os/overview.py +++ b/src/octop/modules/org_os/overview.py @@ -47,6 +47,7 @@ class DualLoopOverview: sidecar_reachable: bool sidecar_url: str start_available: bool + install_ready: bool start_command: str home: str last_sync: str | None @@ -61,6 +62,7 @@ def to_dict(self) -> dict[str, Any]: "sidecar_reachable": self.sidecar_reachable, "sidecar_url": self.sidecar_url, "start_available": self.start_available, + "install_ready": self.install_ready, "start_command": self.start_command, "home": self.home, "last_sync": self.last_sync, @@ -80,6 +82,7 @@ def build_overview( cron_jobs: int = 0, skill_packages: int = 0, start_available: bool = False, + install_ready: bool = False, ) -> DualLoopOverview: status = service.status() tid = service.tenant_id() or "default" @@ -121,12 +124,18 @@ def build_overview( "Data plane: colleagues / experts / skills / MCP / tasks. Control plane: department employees / blueprints / modules / governance.", ] if not status.sidecar.reachable: - notes.append("openXYOS sidecar is offline — start it to sync blueprints and approvals.") + if install_ready: + notes.append( + "openXYOS sidecar is offline — reconnecting the install-time local console." + ) + else: + notes.append("openXYOS sidecar is offline — start it to sync blueprints and approvals.") return DualLoopOverview( enabled=status.enabled, sidecar_reachable=status.sidecar.reachable, sidecar_url=status.sidecar.url, start_available=start_available, + install_ready=install_ready, start_command=status.start_command, home=status.home, last_sync=last_sync, diff --git a/src/octop/modules/org_os/sidecar_launch.py b/src/octop/modules/org_os/sidecar_launch.py index 59697fc1..3cf3020a 100644 --- a/src/octop/modules/org_os/sidecar_launch.py +++ b/src/octop/modules/org_os/sidecar_launch.py @@ -197,6 +197,46 @@ def find_sidecar_launcher() -> Path | None: return None +def sidecar_candidate_roots() -> list[Path]: + """Live / install / portable roots that may carry ``.install-ready``.""" + roots: list[Path] = [] + seen: set[Path] = set() + + def add(path: Path | None) -> None: + if path is None: + return + try: + clean = path.expanduser() + except OSError: + return + if clean in seen: + return + seen.add(clean) + roots.append(clean) + + add(_explicit_openxyos_home()) + add(_localappdata_openxyos()) + base = (os.environ.get("LOCALAPPDATA") or "").strip() + if base: + add(Path(base) / "FreeOS" / "openxyos") + add(_install_openxyos()) + add(PathLayout.from_env().root / "openxyos") + root = portable_root() + if root is not None: + add(root / "org-sidecar") + return roots + + +def sidecar_install_ready() -> bool: + """True when Setup wrote ``.install-ready`` on a live openXYOS tree.""" + for root in sidecar_candidate_roots(): + if (root / ".install-ready").is_file(): + return True + if (root / "org-sidecar" / ".install-ready").is_file(): + return True + return False + + def sidecar_can_start() -> bool: return find_sidecar_runtime() is not None or find_sidecar_launcher() is not None @@ -393,13 +433,15 @@ def start_sidecar(service: OrgModuleService, *, wait: float = 20.0) -> SidecarSt ) -def ensure_sidecar(service: OrgModuleService, *, wait: float = 20.0) -> SidecarStartResult: +def ensure_sidecar(service: OrgModuleService, *, wait: float | None = None) -> SidecarStartResult: """Keep the bundled sidecar up on desktop / first launch. Only auto-starts when the portable ``org-sidecar`` runtime is present. A source-tree ``scripts/run-org-sidecar.sh`` is left for an explicit Start click so host boot never runs ``npm start`` in tests or unpackaged trees. """ + if wait is None: + wait = 20.0 if sidecar_install_ready() else 8.0 health = service.probe_sidecar() command = sidecar_start_command() if health.reachable: diff --git a/src/octop/modules/org_os/source_download.py b/src/octop/modules/org_os/source_download.py index 705833ac..45a609bd 100644 --- a/src/octop/modules/org_os/source_download.py +++ b/src/octop/modules/org_os/source_download.py @@ -3,15 +3,81 @@ from __future__ import annotations import io +import shutil import zipfile from pathlib import Path import httpx from octop.infra.utils.host_dirs import assert_safe_host_path +from octop.infra.utils.win_utf8 import repair_utf8_mojibake OPENXYOS_SOURCE_ZIP = "https://github.com/XYAIStudio/openXYOS/archive/refs/heads/main.zip" +_CJK_START = 0x4E00 +_CJK_END = 0x9FFF + + +def _score_decoded_name(text: str) -> int: + """Prefer real CJK folder names; penalize replacement characters.""" + cjk = sum(1 for ch in text if _CJK_START <= ord(ch) <= _CJK_END) + return cjk - text.count("\ufffd") * 5 + + +def decode_zip_name(info: zipfile.ZipInfo) -> str: + """Decode a zip member path as UTF-8 (or GBK) instead of CP437 mojibake. + + GitHub source zips often set the UTF-8 flag. Windows-built zips with + Chinese folder names frequently omit it and store GBK bytes. Python's + default ``ZipInfo.filename`` then treats those bytes as CP437 — the same + class of mojibake as a knowledge-base mount path on zh-CN Windows. + """ + name = info.filename.replace("\\", "/") + if info.flag_bits & 0x800: + return name + try: + raw = name.encode("cp437") + except UnicodeEncodeError: + return name + best = name + best_score = _score_decoded_name(name) + for encoding in ("utf-8", "gbk", "gb2312", "gb18030"): + try: + decoded = raw.decode(encoding) + except UnicodeDecodeError: + continue + score = _score_decoded_name(decoded) + if score > best_score: + best_score = score + best = decoded.replace("\\", "/") + return best.replace("\\", "/") + + +def _assert_inside(dest: Path, target: Path) -> None: + dest_root = dest.resolve() + resolved = target.resolve() + if resolved != dest_root and dest_root not in resolved.parents: + raise ValueError(f"illegal zip path {target}") + + +def extract_zip_unicode(archive: zipfile.ZipFile, dest: Path) -> None: + """Extract ``archive`` into ``dest`` preserving Unicode folder names.""" + dest.mkdir(parents=True, exist_ok=True) + dest_root = dest.resolve() + for info in archive.infolist(): + name = decode_zip_name(info).lstrip("/") + if not name or name in {".", "./"}: + continue + target = dest_root.joinpath(*Path(name).parts) + _assert_inside(dest_root, target) + is_dir = info.is_dir() or name.endswith("/") + if is_dir: + target.mkdir(parents=True, exist_ok=True) + continue + target.parent.mkdir(parents=True, exist_ok=True) + with archive.open(info) as src, target.open("wb") as out: + shutil.copyfileobj(src, out) + def download_openxyos_source(dest: str, *, url: str = OPENXYOS_SOURCE_ZIP) -> str: """Save the GitHub main-branch source zip into ``dest`` and extract it. @@ -21,6 +87,7 @@ def download_openxyos_source(dest: str, *, url: str = OPENXYOS_SOURCE_ZIP) -> st ``openXYOS-main`` folder under the user-chosen destination. Existing files in that destination are not overwritten outside that folder. """ + dest = repair_utf8_mojibake(dest) target = Path(dest).expanduser() assert_safe_host_path(str(target)) target.mkdir(parents=True, exist_ok=True) @@ -29,7 +96,7 @@ def download_openxyos_source(dest: str, *, url: str = OPENXYOS_SOURCE_ZIP) -> st response.raise_for_status() payload = response.content archive = zipfile.ZipFile(io.BytesIO(payload)) - archive.extractall(target) + extract_zip_unicode(archive, target) zip_path = target / "openXYOS-main.zip" zip_path.write_bytes(payload) return str(target.resolve()) diff --git a/tests/unit/desktop/test_nsis_uninstall.py b/tests/unit/desktop/test_nsis_uninstall.py index d777ebfc..8f704cae 100644 --- a/tests/unit/desktop/test_nsis_uninstall.py +++ b/tests/unit/desktop/test_nsis_uninstall.py @@ -227,11 +227,14 @@ def test_nsis_extracts_runtime_with_quoted_paths_and_aborts_if_incomplete() -> N assert "未能把完整的 openXYOS" in nsi assert "openxyos-runtime.zip missing" in nsh assert "!error" in nsh - assert "probe-openxyos.ps1" in nsh + assert "wait-openxyos.ps1" in nsh + assert "probe-openxyos.ps1" not in nsh assert "/api/health/livez" in nsh assert "SetErrorLevel 68" in nsh assert "LangString OPENXYOS_PROBE_FAIL ${LANG_SIMPCHINESE}" in nsi assert ".install-ready" in nsh + assert "start-sidecar.ps1" in nsh + assert "FreeOS-openXYOS" in nsi workflow = (REPO / ".github" / "workflows" / "octop-desktop.yml").read_text(encoding="utf-8") assert "org-sidecar/openxyos/dist/index.html" in workflow assert "org-sidecar/openxyos/backend/server.ts" in workflow @@ -258,6 +261,36 @@ def test_desktop_readme_documents_uninstall_keep_vs_remove() -> None: assert "install-time" in text.lower() or "during Setup" in text or "安装期" in text +def test_nsis_leaves_user_level_sidecar_running() -> None: + """#29 probed then taskkill'd Node; Organization still asked to 启动边车.""" + nsi = NSI.read_text(encoding="utf-8-sig") + nsh = NSH.read_text(encoding="utf-8") + autostart = nsh[ + nsh.index("!macro wails.writeOpenXYOSAutostart") : nsh.index("!macro wails.probeOpenXYOS") + ] + probe = nsh[ + nsh.index("!macro wails.probeOpenXYOS") : nsh.index("!macro wails.writeUninstaller") + ] + persist = nsi[nsi.index("Function PersistOpenXYOS") : nsi.index("Function LaunchFreeOS")] + assert "start-sidecar.ps1" in autostart + assert "start-sidecar.cmd" in autostart + assert "taskkill.exe" not in autostart + assert "taskkill.exe" not in probe + assert "Start-Process" not in probe + assert "wait-openxyos.ps1" in probe + assert "Call PersistOpenXYOS" in nsh + assert "FreeOS-openXYOS" in persist + assert "A4C6892C-3BA9-11d2-9DEA-00C04FB16162" in persist + assert r"*\FreeOS\openxyos\*" in nsh + uninstall = _uninstall_section(nsi) + assert ( + 'DeleteRegValue HKCU "Software\\Microsoft\\Windows\\CurrentVersion\\Run" "FreeOS-openXYOS"' + in uninstall + ) + assert "LangString OPENXYOS_AUTOSTART ${LANG_SIMPCHINESE}" in nsi + assert "无需「启动边车」" in nsi or "开机自启" in nsi + + def test_organization_source_download_uses_native_folder_picker() -> None: page = ORG_PAGE.read_text(encoding="utf-8") assert "window.prompt" not in page @@ -265,6 +298,12 @@ def test_organization_source_download_uses_native_folder_picker() -> None: assert "canPickDesktopFolder" in page assert "resolveOpenxyosSourceDest" in page assert "browseSourceDest" in page + assert "sidecarRecoverPhase" in page + assert "install_ready" in page + assert "silent: true" in page + zh = (REPO / "dashboard" / "src" / "locales" / "zh.json").read_text(encoding="utf-8") + assert '"startSidecarAction": "重试启动"' in zh + assert "正在自动连接安装期本机 openXYOS" in zh def test_windows_folder_picker_emits_utf8_base64() -> None: diff --git a/tests/unit/test_org_module.py b/tests/unit/test_org_module.py index 66e18f33..4f9b5a0b 100644 --- a/tests/unit/test_org_module.py +++ b/tests/unit/test_org_module.py @@ -185,6 +185,7 @@ def test_overview_reports_real_empty_counts(tmp_path: Path) -> None: assert payload["freeos"]["mcp"] == 1 assert payload["freeos"]["tasks"] == 3 assert payload["sidecar_reachable"] is False + assert payload["install_ready"] is False assert payload["last_loop"] is None assert any("FreeOS does the work" in note for note in payload["notes"]) diff --git a/tests/unit/test_org_notes.py b/tests/unit/test_org_notes.py index 4622249e..c543eda2 100644 --- a/tests/unit/test_org_notes.py +++ b/tests/unit/test_org_notes.py @@ -6,6 +6,7 @@ def test_localize_loop_notes_zh() -> None: [ "generated 12 module skills from the openXYOS catalog", "started but not reachable yet; check logs/org-sidecar.log", + "openXYOS sidecar is offline — reconnecting the install-time local console.", "Local mirror is the durable record. HTTP apply is best-effort.", "control plane accepted the FreeOS ingest", "Loop produced colleagues, published an asset pack, applied department employees, and imported back.", @@ -14,9 +15,10 @@ def test_localize_loop_notes_zh() -> None: ) assert notes[0] == "已从 openXYOS 目录生成 12 个模块技能" assert "尚未可达" in notes[1] - assert "本地镜像" in notes[2] - assert "控制面已接受" in notes[3] - assert "循环已生成同事" in notes[4] + assert "正在自动连接安装期本机控制台" in notes[2] + assert "本地镜像" in notes[3] + assert "控制面已接受" in notes[4] + assert "循环已生成同事" in notes[5] def test_localize_unknown_note_passthrough() -> None: diff --git a/tests/unit/test_sidecar_launch.py b/tests/unit/test_sidecar_launch.py index 1a90f1c1..7b2aa9c7 100644 --- a/tests/unit/test_sidecar_launch.py +++ b/tests/unit/test_sidecar_launch.py @@ -117,6 +117,22 @@ def test_ensure_sidecar_skips_source_tree_script( assert spawned == [] +def test_sidecar_install_ready_reads_marker( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from octop.modules.org_os.sidecar_launch import sidecar_install_ready + + live = tmp_path / "local" / "FreeOS" / "openxyos" + live.mkdir(parents=True) + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "local")) + monkeypatch.delenv("FREEOS_OPENXYOS_HOME", raising=False) + monkeypatch.delenv("OCTOP_GREEN_PACKAGES", raising=False) + monkeypatch.setenv("FREEOS_HOME", str(tmp_path / "unused-home")) + assert sidecar_install_ready() is False + (live / ".install-ready").write_text("live=1\n", encoding="utf-8") + assert sidecar_install_ready() is True + + def test_bundle_rejects_readme_only_workdir( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit/test_source_download.py b/tests/unit/test_source_download.py new file mode 100644 index 00000000..94d297db --- /dev/null +++ b/tests/unit/test_source_download.py @@ -0,0 +1,110 @@ +"""openXYOS source zip must keep Unicode folder names on Windows.""" + +from __future__ import annotations + +import io +import zipfile +from pathlib import Path + +import pytest + +from octop.modules.org_os.source_download import ( + decode_zip_name, + download_openxyos_source, + extract_zip_unicode, +) + + +def _zip_payload(name: str, payload: bytes = b"hi") -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + info = zipfile.ZipInfo(name) + info.flag_bits |= 0x800 + zf.writestr(info, payload) + return buf.getvalue() + + +def _patch_httpx_zip(monkeypatch: pytest.MonkeyPatch, payload: bytes) -> None: + class _Resp: + content = payload + + def raise_for_status(self) -> None: + return None + + class _Client: + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + def __enter__(self) -> _Client: + return self + + def __exit__(self, *args: object) -> None: + return None + + def get(self, url: str) -> _Resp: + assert "openXYOS" in url + return _Resp() + + import octop.modules.org_os.source_download as mod + + monkeypatch.setattr(mod.httpx, "Client", _Client) # type: ignore[attr-defined] + + +def _zip_with_member(name: str, *, utf8_flag: bool, payload: bytes = b"ok") -> zipfile.ZipFile: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + info = zipfile.ZipInfo(name) + if utf8_flag: + info.flag_bits |= 0x800 + else: + info.flag_bits &= ~0x800 + zf.writestr(info, payload) + return zipfile.ZipFile(io.BytesIO(buf.getvalue())) + + +def test_decode_zip_name_honors_utf8_flag() -> None: + archive = _zip_with_member("组织/蓝图/readme.txt", utf8_flag=True) + info = archive.infolist()[0] + assert decode_zip_name(info) == "组织/蓝图/readme.txt" + + +def test_decode_zip_name_recovers_gbk_folder() -> None: + """Windows zips often store GBK bytes without the UTF-8 flag (CP437 mojibake).""" + name = "源码/模块/说明.txt" + info = zipfile.ZipInfo(name.encode("gbk").decode("cp437")) + info.flag_bits = 0 + assert decode_zip_name(info) == name + + +def test_extract_zip_unicode_writes_chinese_folders(tmp_path: Path) -> None: + name = "源码/组织控制台/readme.txt" + archive = _zip_with_member(name, utf8_flag=True, payload=b"hello") + dest = tmp_path / "dest" + extract_zip_unicode(archive, dest) + written = dest / "源码" / "组织控制台" / "readme.txt" + assert written.is_file() + assert written.read_bytes() == b"hello" + + +def test_download_extracts_unicode_and_keeps_zip( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _patch_httpx_zip(monkeypatch, _zip_payload("openXYOS-main/文档/你好.txt")) + dest = tmp_path / "下载目录" + dest.mkdir() + result = download_openxyos_source(str(dest)) + assert Path(result) == dest.resolve() + assert (dest / "openXYOS-main" / "文档" / "你好.txt").read_bytes() == b"hi" + assert (dest / "openXYOS-main.zip").is_file() + + +def test_download_repairs_mojibake_dest(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Picker ACP/CP1252 dest must land in the real UTF-8 Chinese folder.""" + _patch_httpx_zip(monkeypatch, _zip_payload("openXYOS-main/文档/你好.txt")) + dest = tmp_path / "下载目录" + dest.mkdir() + garbled = str(dest).encode("utf-8").decode("cp1252") + assert garbled != str(dest) + result = download_openxyos_source(garbled) + assert Path(result) == dest.resolve() + assert (dest / "openXYOS-main" / "文档" / "你好.txt").read_bytes() == b"hi"