From 322cf8b3205c387a18d24beb684eb7b5e784d015 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 07:45:09 +0000 Subject: [PATCH 1/3] Keep install-time openXYOS running and fix source-download UTF-8 names. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Organization still asked users to 启动边车. Setup now writes a user-level start-sidecar helper, registers HKCU Run / logon task, launches via IShellDispatch2, and leaves the process up after livez. Organization embeds immediately when healthy and auto-starts silently when .install-ready; recover UI is only for failed auto-start. Folder picker and zip extract now preserve Chinese paths end-to-end (UTF-8 base64 from PowerShell, zip UTF-8/GBK name decode). --- CHANGELOG.md | 2 + dashboard/src/api/modules/orgModule.ts | 1 + dashboard/src/locales/en.json | 15 +-- dashboard/src/locales/zh.json | 15 +-- dashboard/src/pages/Organization/index.tsx | 99 ++++++++++++++----- .../pages/Organization/sidecarRecover.test.ts | 52 ++++++++++ .../src/pages/Organization/sidecarRecover.ts | 15 +++ desktop/README.md | 20 +++- desktop/src/build/windows/nsis/project.nsi | 35 ++++++- .../src/build/windows/nsis/wails_tools.nsh | 82 ++++++++++----- desktop/src/folder_dialog.go | 37 +++++-- desktop/src/folder_dialog_test.go | 42 ++++++++ desktop/src/main.go | 5 +- desktop/src/process_sidecar.go | 25 ++++- desktop/src/process_sidecar_test.go | 35 +++++++ src/octop/api/routers/org_module.py | 2 + src/octop/i18n/en.json | 1 + src/octop/i18n/zh.json | 1 + src/octop/infra/server.py | 2 +- src/octop/modules/org_os/notes.py | 7 ++ src/octop/modules/org_os/overview.py | 11 ++- src/octop/modules/org_os/sidecar_launch.py | 44 ++++++++- src/octop/modules/org_os/source_download.py | 67 ++++++++++++- tests/unit/desktop/test_nsis_uninstall.py | 41 +++++++- tests/unit/test_org_module.py | 1 + tests/unit/test_org_notes.py | 8 +- tests/unit/test_sidecar_launch.py | 16 +++ tests/unit/test_source_download.py | 93 +++++++++++++++++ 28 files changed, 684 insertions(+), 90 deletions(-) create mode 100644 dashboard/src/pages/Organization/sidecarRecover.test.ts create mode 100644 dashboard/src/pages/Organization/sidecarRecover.ts create mode 100644 desktop/src/folder_dialog_test.go create mode 100644 tests/unit/test_source_download.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8745dc39..d7ac5078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### 修复 +- 安装期不再在 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 还原中文目录名,避免乱码。 - 模型页注册本机 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 041bf92c..32984c79 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 1a4eeb90..d3705e8a 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/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 374fead6..3aab3065 100644 --- a/desktop/src/folder_dialog.go +++ b/desktop/src/folder_dialog.go @@ -1,6 +1,7 @@ package main import ( + "encoding/base64" "fmt" "log" "os/exec" @@ -40,9 +41,12 @@ func pickFolderNative() (string, error) { } } -func pickFolderWindows() (string, error) { - // FolderBrowserDialog with MyComputer so every drive letter is reachable. - 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 +// the same way a KB mount path used to mojibake. +func folderPickerWindowsScript() string { + return strings.Join( []string{ "Add-Type -AssemblyName System.Windows.Forms", "[void][System.Windows.Forms.Application]::EnableVisualStyles()", @@ -50,28 +54,41 @@ func pickFolderWindows() (string, error) { "$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 }", + "if ($d.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { exit 0 }", + "$bytes = [System.Text.Encoding]::UTF8.GetBytes($d.SelectedPath)", + "[Convert]::ToBase64String($bytes)", }, "; ", ) +} + +func decodeFolderPickerOutput(raw []byte) (string, error) { + token := strings.TrimSpace(string(raw)) + if token == "" { + return "", nil + } + decoded, err := base64.StdEncoding.DecodeString(token) + if err != nil { + return "", fmt.Errorf("folder dialog encoding: %w", err) + } + return strings.TrimSpace(string(decoded)), nil +} + +func pickFolderWindows() (string, error) { cmd := exec.Command( "powershell.exe", "-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-Command", - script, + folderPickerWindowsScript(), ) 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 + return decodeFolderPickerOutput(out) } func pickFolderDarwin() (string, error) { diff --git a/desktop/src/folder_dialog_test.go b/desktop/src/folder_dialog_test.go new file mode 100644 index 00000000..49e20ed9 --- /dev/null +++ b/desktop/src/folder_dialog_test.go @@ -0,0 +1,42 @@ +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") + } + if strings.Contains(script, "$d.SelectedPath }") && !strings.Contains(script, "ToBase64String") { + t.Fatal("must not print SelectedPath as raw ACP text") + } +} + +func TestDecodeFolderPickerOutputPreservesChinese(t *testing.T) { + want := `D:\源码\组织控制台` + raw := []byte(base64.StdEncoding.EncodeToString([]byte(want)) + "\r\n") + got, err := decodeFolderPickerOutput(raw) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +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) + } +} 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 0212ef91..ad6aedc9 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -723,6 +723,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 49304f8f..ad650a94 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -723,6 +723,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..9080fbaa 100644 --- a/src/octop/modules/org_os/source_download.py +++ b/src/octop/modules/org_os/source_download.py @@ -3,6 +3,7 @@ from __future__ import annotations import io +import shutil import zipfile from pathlib import Path @@ -12,6 +13,70 @@ 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. @@ -29,7 +94,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 74b28424..bec4cd99 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,3 +298,9 @@ 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 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..267b497d --- /dev/null +++ b/tests/unit/test_source_download.py @@ -0,0 +1,93 @@ +"""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_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: + name = "openXYOS-main/文档/你好.txt" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + info = zipfile.ZipInfo(name) + info.flag_bits |= 0x800 + zf.writestr(info, b"hi") + payload = buf.getvalue() + + 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] + 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() From eaf0d979aa76cac1257d10f3416df214b892dd9d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 08:11:55 +0000 Subject: [PATCH 2/3] Align source-download dest and folder picker with UTF-8 repair. Repair CP1252/Latin-1 dest paths before extract, decode Windows picker output as UTF-8 Base64 (with mojibake fallback), and share the same repair helper as the KB-mount path so Chinese folder names survive end-to-end. --- dashboard/src/utils/desktopFolder.test.ts | 26 +++- dashboard/src/utils/desktopFolder.ts | 7 +- dashboard/src/utils/utf8Mojibake.test.ts | 18 +++ dashboard/src/utils/utf8Mojibake.ts | 55 +++++++ desktop/src/folder_dialog.go | 152 ++++++++++++++++++-- desktop/src/folder_dialog_test.go | 45 ++++-- src/octop/infra/utils/win_utf8.py | 21 +++ src/octop/modules/org_os/source_download.py | 2 + tests/unit/infra/utils/test_win_utf8.py | 13 ++ tests/unit/test_source_download.py | 79 ++++++---- 10 files changed, 362 insertions(+), 56 deletions(-) create mode 100644 dashboard/src/utils/utf8Mojibake.test.ts create mode 100644 dashboard/src/utils/utf8Mojibake.ts create mode 100644 src/octop/infra/utils/win_utf8.py create mode 100644 tests/unit/infra/utils/test_win_utf8.py diff --git a/dashboard/src/utils/desktopFolder.test.ts b/dashboard/src/utils/desktopFolder.test.ts index a92304c8..7d84e724 100644 --- a/dashboard/src/utils/desktopFolder.test.ts +++ b/dashboard/src/utils/desktopFolder.test.ts @@ -24,13 +24,35 @@ describe("desktopFolder", () => { 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(invoke).toHaveBeenCalledWith( + "wails:event:emit:desktop:select-folder", + ); expect(path).toBe("D:\\Projects\\demo"); }); + it("repairs UTF-8 Chinese paths that arrived as Windows-1252 mojibake", async () => { + const bytes = new TextEncoder().encode("D:\\项目"); + const garbled = Array.from(bytes, (byte) => String.fromCharCode(byte)).join( + "", + ); + const invoke = vi.fn(() => { + window.dispatchEvent( + new CustomEvent("freeos-folder-selected", { detail: garbled }), + ); + }); + 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.toBe("D:\\项目"); + }); + it("returns null when the user cancels", async () => { const invoke = vi.fn(() => { - window.dispatchEvent(new CustomEvent("freeos-folder-selected", { detail: "" })); + window.dispatchEvent( + new CustomEvent("freeos-folder-selected", { detail: "" }), + ); }); const win = { _wails: { invoke }, diff --git a/dashboard/src/utils/desktopFolder.ts b/dashboard/src/utils/desktopFolder.ts index 6b7ada04..d447dfa1 100644 --- a/dashboard/src/utils/desktopFolder.ts +++ b/dashboard/src/utils/desktopFolder.ts @@ -1,4 +1,5 @@ import { isDesktopShell } from "./desktopChrome"; +import { repairUtf8Mojibake } from "./utf8Mojibake"; type DesktopWindow = Window & { _wails?: { invoke?: (message: string) => void }; @@ -29,7 +30,11 @@ export function pickDesktopFolder( }; const onSelected = (event: Event) => { const detail = (event as CustomEvent).detail; - finish(typeof detail === "string" && detail.trim() ? detail.trim() : null); + finish( + typeof detail === "string" && detail.trim() + ? repairUtf8Mojibake(detail.trim()) + : null, + ); }; const timer = window.setTimeout(() => finish(null), timeoutMs); win.addEventListener(FOLDER_EVENT, onSelected as EventListener); diff --git a/dashboard/src/utils/utf8Mojibake.test.ts b/dashboard/src/utils/utf8Mojibake.test.ts new file mode 100644 index 00000000..1f3324f6 --- /dev/null +++ b/dashboard/src/utils/utf8Mojibake.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { repairUtf8Mojibake } from "./utf8Mojibake"; + +describe("repairUtf8Mojibake", () => { + it("restores UTF-8 Chinese that was decoded as Windows-1252", () => { + const bytes = new TextEncoder().encode("项目结构.png"); + const garbled = Array.from(bytes, (byte) => String.fromCharCode(byte)).join( + "", + ); + expect(garbled).not.toBe("项目结构.png"); + expect(repairUtf8Mojibake(garbled)).toBe("项目结构.png"); + }); + + it("leaves already-valid Unicode and ASCII alone", () => { + expect(repairUtf8Mojibake("项目结构.png")).toBe("项目结构.png"); + expect(repairUtf8Mojibake("D:\\docs\\notes")).toBe("D:\\docs\\notes"); + }); +}); diff --git a/dashboard/src/utils/utf8Mojibake.ts b/dashboard/src/utils/utf8Mojibake.ts new file mode 100644 index 00000000..8d06c848 --- /dev/null +++ b/dashboard/src/utils/utf8Mojibake.ts @@ -0,0 +1,55 @@ +/** Map Unicode code points that CP1252 uses for bytes 0x80–0x9F back to those bytes. */ +const CP1252_REVERSE: Record = { + 0x20ac: 0x80, + 0x201a: 0x82, + 0x0192: 0x83, + 0x201e: 0x84, + 0x2026: 0x85, + 0x2020: 0x86, + 0x2021: 0x87, + 0x02c6: 0x88, + 0x2030: 0x89, + 0x0160: 0x8a, + 0x2039: 0x8b, + 0x0152: 0x8c, + 0x017d: 0x8e, + 0x2018: 0x91, + 0x2019: 0x92, + 0x201c: 0x93, + 0x201d: 0x94, + 0x2022: 0x95, + 0x2013: 0x96, + 0x2014: 0x97, + 0x02dc: 0x98, + 0x2122: 0x99, + 0x0161: 0x9a, + 0x203a: 0x9b, + 0x0153: 0x9c, + 0x017e: 0x9e, + 0x0178: 0x9f, +}; + +function cp1252Byte(code: number): number | null { + if (code <= 0xff) return code; + return CP1252_REVERSE[code] ?? 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; + const bytes = new Uint8Array(text.length); + for (let i = 0; i < text.length; i += 1) { + const mapped = cp1252Byte(text.charCodeAt(i)); + if (mapped === null) return text; + bytes[i] = mapped; + } + try { + const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + return decoded || text; + } catch { + return text; + } +} diff --git a/desktop/src/folder_dialog.go b/desktop/src/folder_dialog.go index 3aab3065..44dc9f3f 100644 --- a/desktop/src/folder_dialog.go +++ b/desktop/src/folder_dialog.go @@ -7,6 +7,8 @@ import ( "os/exec" "runtime" "strings" + "unicode/utf16" + "unicode/utf8" ) func (a *App) emitPickedFolder() { @@ -43,8 +45,7 @@ func pickFolderNative() (string, error) { // 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 -// the same way a KB mount path used to mojibake. +// Windows). Interpreting those bytes as UTF-8 garbles Chinese folder names. func folderPickerWindowsScript() string { return strings.Join( []string{ @@ -62,18 +63,6 @@ func folderPickerWindowsScript() string { ) } -func decodeFolderPickerOutput(raw []byte) (string, error) { - token := strings.TrimSpace(string(raw)) - if token == "" { - return "", nil - } - decoded, err := base64.StdEncoding.DecodeString(token) - if err != nil { - return "", fmt.Errorf("folder dialog encoding: %w", err) - } - return strings.TrimSpace(string(decoded)), nil -} - func pickFolderWindows() (string, error) { cmd := exec.Command( "powershell.exe", @@ -91,6 +80,141 @@ func pickFolderWindows() (string, error) { return decodeFolderPickerOutput(out) } +func decodeFolderPickerOutput(out []byte) (string, error) { + raw := strings.TrimSpace(normalizePickerBytes(out)) + if raw == "" { + return "", nil + } + if i := strings.LastIndex(raw, "\n"); i >= 0 { + if last := strings.TrimSpace(raw[i+1:]); last != "" { + raw = last + } + } + if decoded, err := base64.StdEncoding.DecodeString(raw); err == nil && len(decoded) > 0 && utf8.Valid(decoded) { + return string(decoded), nil + } + return repairUTF8Mojibake(raw), nil +} + +func normalizePickerBytes(out []byte) string { + if len(out) >= 2 && out[0] == 0xFF && out[1] == 0xFE { + return utf16LEToString(out[2:]) + } + if looksLikeUTF16LE(out) { + return utf16LEToString(out) + } + return string(out) +} + +func looksLikeUTF16LE(b []byte) bool { + if len(b) < 4 || len(b)%2 != 0 { + return false + } + zeros := 0 + pairs := 0 + for i := 1; i < len(b); i += 2 { + pairs++ + if b[i] == 0 { + zeros++ + } + } + return pairs > 0 && zeros*2 >= pairs +} + +func utf16LEToString(b []byte) string { + if len(b)%2 == 1 { + b = b[:len(b)-1] + } + u := make([]uint16, 0, len(b)/2) + for i := 0; i+1 < len(b); i += 2 { + u = append(u, uint16(b[i])|uint16(b[i+1])<<8) + } + return string(utf16.Decode(u)) +} + +func repairUTF8Mojibake(text string) string { + raw := make([]byte, 0, len(text)) + for _, r := range text { + if mapped, ok := cp1252Byte(r); ok { + raw = append(raw, mapped) + continue + } + return text + } + if !utf8.Valid(raw) { + return text + } + repaired := string(raw) + if repaired != text { + return repaired + } + return text +} + +func cp1252Byte(r rune) (byte, bool) { + if r <= 0xFF { + return byte(r), true + } + switch r { + case 0x20AC: + return 0x80, true + case 0x201A: + return 0x82, true + case 0x0192: + return 0x83, true + case 0x201E: + return 0x84, true + case 0x2026: + return 0x85, true + case 0x2020: + return 0x86, true + case 0x2021: + return 0x87, true + case 0x02C6: + return 0x88, true + case 0x2030: + return 0x89, true + case 0x0160: + return 0x8A, true + case 0x2039: + return 0x8B, true + case 0x0152: + return 0x8C, true + case 0x017D: + return 0x8E, true + case 0x2018: + return 0x91, true + case 0x2019: + return 0x92, true + case 0x201C: + return 0x93, true + case 0x201D: + return 0x94, true + case 0x2022: + return 0x95, true + case 0x2013: + return 0x96, true + case 0x2014: + return 0x97, true + case 0x02DC: + return 0x98, true + case 0x2122: + return 0x99, true + case 0x0161: + return 0x9A, true + case 0x203A: + return 0x9B, true + case 0x0153: + return 0x9C, true + case 0x017E: + return 0x9E, true + case 0x0178: + return 0x9F, true + default: + return 0, false + } +} + func pickFolderDarwin() (string, error) { cmd := exec.Command( "osascript", diff --git a/desktop/src/folder_dialog_test.go b/desktop/src/folder_dialog_test.go index 49e20ed9..2e9c575b 100644 --- a/desktop/src/folder_dialog_test.go +++ b/desktop/src/folder_dialog_test.go @@ -14,20 +14,34 @@ func TestFolderPickerWindowsScriptUsesUTF8Base64(t *testing.T) { if !strings.Contains(script, "[Convert]::ToBase64String") { t.Fatal("Windows folder picker must base64 the UTF-8 path") } - if strings.Contains(script, "$d.SelectedPath }") && !strings.Contains(script, "ToBase64String") { - t.Fatal("must not print SelectedPath as raw ACP text") +} + +func TestDecodeFolderPickerOutputPrefersUTF8Base64(t *testing.T) { + path := `D:\项目\资料` + encoded := base64.StdEncoding.EncodeToString([]byte(path)) + got, err := decodeFolderPickerOutput([]byte(encoded + "\r\n")) + if err != nil { + t.Fatal(err) + } + if got != path { + t.Fatalf("got %q want %q", got, path) } } -func TestDecodeFolderPickerOutputPreservesChinese(t *testing.T) { - want := `D:\源码\组织控制台` - raw := []byte(base64.StdEncoding.EncodeToString([]byte(want)) + "\r\n") - got, err := decodeFolderPickerOutput(raw) +func TestDecodeFolderPickerOutputReadsUTF16LE(t *testing.T) { + path := `D:\项目` + encoded := base64.StdEncoding.EncodeToString([]byte(path)) + utf16 := make([]byte, 0, 2+len(encoded)*2) + utf16 = append(utf16, 0xFF, 0xFE) + for i := 0; i < len(encoded); i++ { + utf16 = append(utf16, encoded[i], 0) + } + got, err := decodeFolderPickerOutput(utf16) if err != nil { t.Fatal(err) } - if got != want { - t.Fatalf("got %q want %q", got, want) + if got != path { + t.Fatalf("got %q want %q", got, path) } } @@ -40,3 +54,18 @@ func TestDecodeFolderPickerOutputEmpty(t *testing.T) { t.Fatalf("empty picker should be %q, got %q", "", got) } } + +func TestRepairUTF8MojibakeChineseFilename(t *testing.T) { + mojibake := string([]rune{ + 0x00E9, 0x00A1, 0x00B9, + 0x00E7, 0x009B, 0x00AE, + 0x00E7, 0x00BB, 0x201C, + 0x00E6, 0x017E, 0x201E, + }) + ".png" + if got := repairUTF8Mojibake(mojibake); got != "项目结构.png" { + t.Fatalf("got %q", got) + } + if got := repairUTF8Mojibake(`D:\docs`); got != `D:\docs` { + t.Fatalf("ascii changed: %q", got) + } +} diff --git a/src/octop/infra/utils/win_utf8.py b/src/octop/infra/utils/win_utf8.py new file mode 100644 index 00000000..50dde9fd --- /dev/null +++ b/src/octop/infra/utils/win_utf8.py @@ -0,0 +1,21 @@ +"""Repair UTF-8 text that Windows bridges exposed as Latin-1 / CP1252.""" + +from __future__ import annotations + + +def repair_utf8_mojibake(text: str) -> str: + """Decode *text* when it is UTF-8 bytes mis-read as Latin-1 or CP1252. + + Typical case: ``项目结构.png`` arrives as ``项目结构.png``. Already-valid + Unicode (including CJK that cannot encode as Latin-1) is returned unchanged. + """ + if not text: + return text + for encoding in ("cp1252", "latin-1"): + try: + repaired = text.encode(encoding).decode("utf-8") + except (UnicodeEncodeError, UnicodeDecodeError): + continue + if repaired != text: + return repaired + return text diff --git a/src/octop/modules/org_os/source_download.py b/src/octop/modules/org_os/source_download.py index 9080fbaa..45a609bd 100644 --- a/src/octop/modules/org_os/source_download.py +++ b/src/octop/modules/org_os/source_download.py @@ -10,6 +10,7 @@ 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" @@ -86,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) diff --git a/tests/unit/infra/utils/test_win_utf8.py b/tests/unit/infra/utils/test_win_utf8.py new file mode 100644 index 00000000..e9e95e3a --- /dev/null +++ b/tests/unit/infra/utils/test_win_utf8.py @@ -0,0 +1,13 @@ +from octop.infra.utils.win_utf8 import repair_utf8_mojibake + + +def test_repair_utf8_mojibake_restores_chinese_filename() -> None: + garbled = "项目结构.png".encode().decode("cp1252") + assert garbled != "项目结构.png" + assert repair_utf8_mojibake(garbled) == "项目结构.png" + + +def test_repair_utf8_mojibake_leaves_real_unicode_and_ascii() -> None: + assert repair_utf8_mojibake("项目结构.png") == "项目结构.png" + assert repair_utf8_mojibake(r"D:\docs\notes") == r"D:\docs\notes" + assert repair_utf8_mojibake("") == "" diff --git a/tests/unit/test_source_download.py b/tests/unit/test_source_download.py index 267b497d..94d297db 100644 --- a/tests/unit/test_source_download.py +++ b/tests/unit/test_source_download.py @@ -15,6 +15,41 @@ ) +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: @@ -54,40 +89,22 @@ def test_extract_zip_unicode_writes_chinese_folders(tmp_path: Path) -> None: def test_download_extracts_unicode_and_keeps_zip( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - name = "openXYOS-main/文档/你好.txt" - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w") as zf: - info = zipfile.ZipInfo(name) - info.flag_bits |= 0x800 - zf.writestr(info, b"hi") - payload = buf.getvalue() - - 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] + _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" From baac1923788b54d6942c08e24be47bb1ab7ecbb9 Mon Sep 17 00:00:00 2001 From: Ramirez27 Margaret Date: Wed, 16 Sep 2026 16:34:14 +0800 Subject: [PATCH 3/3] Format test_nsis_uninstall after merge --- tests/unit/desktop/test_nsis_uninstall.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/desktop/test_nsis_uninstall.py b/tests/unit/desktop/test_nsis_uninstall.py index 34002645..8f704cae 100644 --- a/tests/unit/desktop/test_nsis_uninstall.py +++ b/tests/unit/desktop/test_nsis_uninstall.py @@ -314,4 +314,3 @@ def test_windows_folder_picker_emits_utf8_base64() -> None: process = (REPO / "desktop" / "src" / "process.go").read_text(encoding="utf-8") assert "PYTHONUTF8" in process assert "utf-8" in process -