From c31dd59c9bb860f06d5ac0e639eab023ccbd604b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 13:16:12 +0000 Subject: [PATCH 1/4] Extract openXYOS Windows setup into an independent provisioner. NSIS now copies the sealed zip plus openxyos-provision.ps1 and waits for that unelevated process. Extract, start, livez, .install-ready, and autostart live in one script instead of FileWrite cmd/goto glue. --- CHANGELOG.md | 2 +- desktop/README.md | 87 +++-- desktop/src/build/stage_openxyos_runtime.py | 5 +- desktop/src/build/windows/nsis/project.nsi | 153 ++++++--- .../src/build/windows/nsis/wails_tools.nsh | 222 ++---------- .../src/build/windows/openxyos-provision.cmd | 4 + .../src/build/windows/openxyos-provision.ps1 | 324 ++++++++++++++++++ desktop/src/build/windows/start-sidecar.cmd | 4 + desktop/src/build/windows/start-sidecar.ps1 | 101 ++++++ desktop/src/process_sidecar.go | 4 +- src/octop/modules/org_os/sidecar_launch.py | 2 +- tests/unit/desktop/test_nsis_uninstall.py | 153 ++++----- .../unit/desktop/test_openxyos_provisioner.py | 105 ++++++ 13 files changed, 793 insertions(+), 373 deletions(-) create mode 100644 desktop/src/build/windows/openxyos-provision.cmd create mode 100644 desktop/src/build/windows/openxyos-provision.ps1 create mode 100644 desktop/src/build/windows/start-sidecar.cmd create mode 100644 desktop/src/build/windows/start-sidecar.ps1 create mode 100644 tests/unit/desktop/test_openxyos_provisioner.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5015280d..e8847d62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### 修复 -- Windows 安装包解压 openXYOS 时修正 `extract-openxyos.cmd` 的 `cmd.exe` 标签(`:liveNodeOk`)。原先写成 `liveNodeOk:`,goto 找不到标签会以退出码 1 结束;文件已齐备时 livez 在 90 秒内未就绪改为警告并继续安装,不再整包 Abort。`.install-ready` 在 layout 通过后、livez 探测前写入,HKCU Run / 登录任务保留,组织页可随后拉起边车。 +- Windows 安装把 openXYOS 预配置拆成独立进程 `openxyos-provision.ps1`(与单独安装 openXYOS 相同:`tar.exe` 解压、中等完整性启动 FE+BE、等待 livez)。NSIS 只复制密封 zip 与预配置程序并等待退出码,不再 FileWrite `extract-openxyos.cmd` / `goto liveNodeOk` / `wait-openxyos.ps1`。成功必须同时满足磁盘运行环境 + 服务已起 + `http://127.0.0.1:3780/api/health/livez`,此时才写入 `.install-ready`;livez 失败是预配置失败(安装 Abort),不再警告后继续。 - 安装期不再在 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。 diff --git a/desktop/README.md b/desktop/README.md index 91ed36a0..f6d5a0df 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -23,17 +23,15 @@ Same precedence as the FreeOS CLI/server: - Green runtime extract → `{home}/portable/` - Organization sidecar data → `{home}/org-os/` - Local openXYOS workdir (FE+BE) → `%LOCALAPPDATA%\FreeOS\openxyos` on Windows, - else `{home}/openxyos`. The NSIS installer expands a prebuilt - `openxyos-runtime.zip` **directly into that live workdir** during Setup - (and keeps `$INSTDIR\openxyos` / `$INSTDIR\openxyos-runtime` as backups). - Install fails if `node` or `dist` is missing. If files are in place but - `http://127.0.0.1:3780/api/health/livez` is still down after the install-time - wait, Setup warns and continues — HKCU Run / the logon task stay registered - so Organization can attach later. Setup then leaves a **user-level** sidecar - running (HKCU Run `FreeOS-openXYOS` + `start-sidecar.ps1`) so Organization - embeds immediately when livez is already up — no first-open copy and no - 「启动边车」 click. First FreeOS start only attaches to that live tree if - the port is already healthy. + else `{home}/openxyos`. The NSIS installer copies a prebuilt + `openxyos-runtime.zip` plus `openxyos-provision.ps1` into `$INSTDIR`, then + launches that **independent provisioner** (unelevated / medium IL). The + provisioner extracts with `tar.exe`, starts FE+BE, waits for + `http://127.0.0.1:3780/api/health/livez`, and writes `.install-ready` only + when healthy. A README-only tree or a livez miss is a **provision failure** + (Setup aborts). Success leaves a user-level sidecar running (HKCU Run + `FreeOS-openXYOS` + `start-sidecar.ps1`) so Organization embeds immediately + — no first-open copy and no 「启动边车」 click. - Shell prefs → `{home}/desktop-settings.json` ## Windows install finish @@ -46,30 +44,59 @@ as High integrity. Uncheck to skip. Chinese installer strings are compiled with `makensis -INPUTCHARSET UTF8` from a UTF-8 BOM `project.nsi`. The install log is no longer only `FreeOS.exe` + shortcuts. A healthy package -also copies `openxyos-runtime.zip` (prebuilt Node + openXYOS frontend/backend) -and extracts it 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** only if `node\node.exe` or `dist\index.html` is -missing — a README-only tree is not a successful install. A livez -timeout after those files exist is a warning, not an abort. -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 -mutate. The app’s live root is `%LOCALAPPDATA%\FreeOS\openxyos`. Setup -reads the installing user’s `LOCALAPPDATA` environment variable (not NSIS -`$LOCALAPPDATA` after `SetShellVarContext all`, which is ProgramData) and -writes the complete FE+BE there so first launch does not pay a copy/unpack -cost. Organization then embeds `http://127.0.0.1:3780` without a manual +copies `openxyos-runtime.zip` (prebuilt Node + openXYOS frontend/backend) +and the checked-in `openxyos-provision.ps1` / `start-sidecar.ps1`. NSIS does +**not** FileWrite `extract-openxyos.cmd` or `goto` labels. After the FreeOS +shell files and shortcuts are in place, Setup launches the provisioner as a +separate unelevated process (IShellDispatch2 / explorer token — no High-IL +Node) and waits for its exit code. + +That process is the same shape as installing openXYOS alone: `tar.exe` +extract into `%LOCALAPPDATA%\FreeOS\openxyos`, start Node, wait until +`http://127.0.0.1:3780/api/health/livez` is healthy, then stamp +`.install-ready` and register HKCU Run `FreeOS-openXYOS`. Success requires +runtime on disk **and** services up **and** livez. A README-only tree or a +livez miss aborts Setup (error 67 = extract/layout, 68 = start/livez / +provisioner never returned). Read +`%LOCALAPPDATA%\FreeOS\openxyos\provision.log` for the failed stage +(`extract` / `start` / `livez`); do not treat a start failure as a generic +port-3780 check. + +`$INSTDIR\openxyos-runtime.zip` is the sealed Program Files backup. The +elevated installer does not create the live tree itself (that would stamp +admin ACLs). First FreeOS launch attaches to the already-provisioned live +root. Organization embeds `http://127.0.0.1:3780` without a manual 「启动边车」 click. Downloading the latest openXYOS source uses the same native folder picker as the project workdir (any drive), not a typed path only. Override the workdir with `FREEOS_OPENXYOS_HOME`. +### Verify a Windows amd64 silent / elevated install + +```bat +FreeOS-desktop-windows-amd64-.exe /S +echo %ERRORLEVEL% +``` + +Exit `0` means the FreeOS shell copy **and** the independent openXYOS +provisioner both succeeded. Then: + +```bat +dir "%LOCALAPPDATA%\FreeOS\openxyos\node\node.exe" +type "%LOCALAPPDATA%\FreeOS\openxyos\.install-ready" +curl http://127.0.0.1:3780/api/health/livez +reg query HKCU\Software\Microsoft\Windows\CurrentVersion\Run /v FreeOS-openXYOS +``` + +To retry only openXYOS (same process Setup launched): + +```bat +powershell -NoProfile -ExecutionPolicy Bypass -File "C:\Program Files\FreeOS\openxyos-provision.ps1" +``` + +Elevated (default machine) install still launches that script at medium +integrity. Do not `Run as administrator` the provisioner itself. + ## Windows uninstall The NSIS uninstaller (Settings → Apps, or `uninstall.exe` in the install diff --git a/desktop/src/build/stage_openxyos_runtime.py b/desktop/src/build/stage_openxyos_runtime.py index 50704264..28e866dd 100644 --- a/desktop/src/build/stage_openxyos_runtime.py +++ b/desktop/src/build/stage_openxyos_runtime.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 """Build openxyos-runtime.zip from a green portable zip or staging tree. -The Windows NSIS installer copies this zip into $INSTDIR and expands it to -$INSTDIR\\openxyos so the finish log shows FE+BE, not only FreeOS.exe. +The Windows NSIS installer copies this zip next to openxyos-provision.ps1. +The independent provisioner extracts it with tar.exe into +%LOCALAPPDATA%\\FreeOS\\openxyos — NSIS does not expand the zip itself. """ from __future__ import annotations diff --git a/desktop/src/build/windows/nsis/project.nsi b/desktop/src/build/windows/nsis/project.nsi index 43e6c343..670d255b 100644 --- a/desktop/src/build/windows/nsis/project.nsi +++ b/desktop/src/build/windows/nsis/project.nsi @@ -52,30 +52,20 @@ ManifestDPIAware true LangString FINISH_RUN ${LANG_SIMPCHINESE} "运行 FreeOS" LangString FINISH_RUN ${LANG_ENGLISH} "Run FreeOS" -LangString OPENXYOS_WORKDIR ${LANG_SIMPCHINESE} "创建 openXYOS 工作目录(写入 %LOCALAPPDATA%\\FreeOS\\openxyos,安装期完成部署)" -LangString OPENXYOS_WORKDIR ${LANG_ENGLISH} "Create the openXYOS work directory (%LOCALAPPDATA%\\FreeOS\\openxyos; deploy during Setup)" -LangString OPENXYOS_COPY_ZIP ${LANG_SIMPCHINESE} "复制 openXYOS 运行包(前端+后端)" -LangString OPENXYOS_COPY_ZIP ${LANG_ENGLISH} "Copy the openXYOS runtime payload (frontend + backend)" -LangString OPENXYOS_EXTRACT ${LANG_SIMPCHINESE} "解压 openXYOS 前端与后端到 %LOCALAPPDATA%\\FreeOS\\openxyos" -LangString OPENXYOS_EXTRACT ${LANG_ENGLISH} "Extract openXYOS frontend and backend into %LOCALAPPDATA%\\FreeOS\\openxyos" -LangString OPENXYOS_FE_OK ${LANG_SIMPCHINESE} "已就绪:%LOCALAPPDATA%\\FreeOS\\openxyos\\dist\\index.html" -LangString OPENXYOS_FE_OK ${LANG_ENGLISH} "Ready: %LOCALAPPDATA%\\FreeOS\\openxyos dist\\index.html" -LangString OPENXYOS_NODE_OK ${LANG_SIMPCHINESE} "已就绪:bundled Node(node.exe)" -LangString OPENXYOS_NODE_OK ${LANG_ENGLISH} "Ready: bundled Node (node.exe)" -LangString OPENXYOS_STAGED_OK ${LANG_SIMPCHINESE} "已从 openxyos-runtime 备份展开到本机工作目录" -LangString OPENXYOS_STAGED_OK ${LANG_ENGLISH} "Expanded the sealed runtime backup into the live workdir" -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_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_WARN ${LANG_SIMPCHINESE} "openXYOS 文件已就绪,但 http://127.0.0.1:3780/api/health/livez 暂未响应。安装将继续;登录后会自动拉起本机服务,也可稍后在组织页重试。" -LangString OPENXYOS_PROBE_WARN ${LANG_ENGLISH} "openXYOS files are in place, but http://127.0.0.1:3780/api/health/livez is not ready yet. Setup will continue; the service auto-starts at logon, or retry from Organization later." +LangString OPENXYOS_COPY_ZIP ${LANG_SIMPCHINESE} "复制 openXYOS 密封运行包与独立预配置程序" +LangString OPENXYOS_COPY_ZIP ${LANG_ENGLISH} "Copy the sealed openXYOS runtime zip and the independent provisioner" +LangString OPENXYOS_PROVISION ${LANG_SIMPCHINESE} "正在启动独立的 openXYOS 预配置进程(解压、部署并启动前端/后端)" +LangString OPENXYOS_PROVISION ${LANG_ENGLISH} "Starting the independent openXYOS provision process (extract, deploy, start frontend/backend)" +LangString OPENXYOS_PROVISION_OK ${LANG_SIMPCHINESE} "openXYOS 预配置成功:本机运行环境已就绪,服务已启动,健康检查通过" +LangString OPENXYOS_PROVISION_OK ${LANG_ENGLISH} "openXYOS provision succeeded: runtime on disk, services up, health check passed" +LangString OPENXYOS_PROVISION_CODE ${LANG_SIMPCHINESE} "openXYOS 预配置退出码 " +LangString OPENXYOS_PROVISION_CODE ${LANG_ENGLISH} "openXYOS provisioner exit code " +LangString OPENXYOS_PROVISION_TIMEOUT ${LANG_SIMPCHINESE} "独立的 openXYOS 预配置进程没有返回退出码。请查看 %LOCALAPPDATA%\\FreeOS\\openxyos\\provision.log,或重新运行安装目录中的 openxyos-provision.ps1。" +LangString OPENXYOS_PROVISION_TIMEOUT ${LANG_ENGLISH} "The independent openXYOS provisioner did not return an exit code. See %LOCALAPPDATA%\\FreeOS\\openxyos\\provision.log, or re-run openxyos-provision.ps1 from the install folder." +LangString OPENXYOS_PROVISION_FAIL ${LANG_SIMPCHINESE} "openXYOS 预配置失败。这是独立预配置进程的失败,不是 FreeOS 外壳拷贝失败。请查看 %LOCALAPPDATA%\\FreeOS\\openxyos\\provision.log 里的阶段(extract / start / livez)并重试该进程;不要只把原因归为「检查 3780 端口」。" +LangString OPENXYOS_PROVISION_FAIL ${LANG_ENGLISH} "openXYOS provision failed. This is a failure of the independent provision process, not the FreeOS shell copy. Read the stage (extract / start / livez) in %LOCALAPPDATA%\\FreeOS\\openxyos\\provision.log and retry that process; do not treat this as a generic port-3780 check." +LangString OPENXYOS_AUTOSTART ${LANG_SIMPCHINESE} "预配置进程会登记本机 openXYOS 常驻启动(登录后自动拉起,无需「启动边车」)" +LangString OPENXYOS_AUTOSTART ${LANG_ENGLISH} "The provisioner registers persistent openXYOS auto-start (logon; no Start sidecar click)" LangString UN_FREEOS_RUNNING ${LANG_SIMPCHINESE} "检测到 FreeOS 仍在运行(主程序、主机或组织侧车)。$\r$\n$\r$\n继续将结束这些进程,并删除安装目录中的程序文件。$\r$\n用户数据(%USERPROFILE%\.freeos)会保留。$\r$\n$\r$\n要继续卸载吗?" LangString UN_FREEOS_RUNNING ${LANG_ENGLISH} "FreeOS is still running (shell, host, or organization sidecar).$\r$\n$\r$\nContinuing will stop those processes and remove program files from the install folder.$\r$\nUser data (%USERPROFILE%\.freeos) is kept.$\r$\n$\r$\nContinue uninstall?" @@ -121,6 +111,10 @@ Section !insertmacro wails.associateCustomProtocols !insertmacro wails.writeUninstaller + + # FreeOS shell files + shortcuts are done. openXYOS is an independent + # provision process: extract + start FE/BE + livez. Failure aborts Setup. + !insertmacro wails.provisionOpenXYOS SectionEnd Section "uninstall" @@ -147,6 +141,8 @@ Section "uninstall" DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "FreeOS-openXYOS" nsExec::ExecToLog 'schtasks.exe /Delete /TN "FreeOS-openXYOS" /F' Pop $0 + nsExec::ExecToLog 'schtasks.exe /Delete /TN "FreeOS-openXYOS-provision" /F' + Pop $0 !insertmacro wails.unassociateFiles !insertmacro wails.unassociateCustomProtocols @@ -155,26 +151,99 @@ 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 +# Launch openxyos-provision.ps1 as the unelevated user. An admin +# CreateProcess would High-integrity ~/.freeos and Node; Organization +# would then look broken even if the shell copy succeeded. +Function LaunchOpenXYOSProvision !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"' + Delete "$R6\FreeOS\openxyos\.provision-exit" + Delete "$R6\FreeOS\openxyos\.provision-running" + Delete "$R6\FreeOS\openxyos\.install-ready" + Delete "$TEMP\FreeOS-openxyos-provision.exit" + !if "${WAILS_INSTALL_SCOPE}" == "user" + nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\openxyos-provision.ps1"' Pop $0 - Exec '"$WINDIR\explorer.exe" "$R6\FreeOS\openxyos\start-sidecar.cmd"' - ${EndIf} + !else + 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 -ExecutionPolicy Bypass -File $\"$INSTDIR\openxyos-provision.ps1\"", w "$INSTDIR", w "open", i 0)' + System::Call "$0->2()" + ${Else} + nsExec::ExecToLog 'schtasks.exe /Create /TN "FreeOS-openXYOS-provision" /SC ONCE /ST 00:00 /RL LIMITED /F /TR "\"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe\" -NoProfile -ExecutionPolicy Bypass -File \"$INSTDIR\openxyos-provision.ps1\""' + Pop $0 + nsExec::ExecToLog 'schtasks.exe /Run /TN "FreeOS-openXYOS-provision"' + Pop $0 + Exec '"$WINDIR\explorer.exe" "$INSTDIR\openxyos-provision.cmd"' + ${EndIf} + !endif +FunctionEnd + +# Poll the provisioner's sentinel. 0 + .install-ready is success. +# Any other outcome aborts Setup (do not soft-skip livez). +Function WaitOpenXYOSProvision + !insertmacro wails.userLocalAppData + StrCpy $R8 "0" + oxProvWait: + IfFileExists "$R6\FreeOS\openxyos\.provision-exit" oxProvReadLive + IfFileExists "$TEMP\FreeOS-openxyos-provision.exit" oxProvReadTemp + IntOp $R8 $R8 + 1 + IntCmp $R8 240 oxProvTimeout 0 oxProvTimeout + IntCmp $R8 45 oxProvCheckLaunch oxProvSleep oxProvSleep + oxProvCheckLaunch: + IfFileExists "$R6\FreeOS\openxyos\.provision-running" oxProvSleep + IfFileExists "$R6\FreeOS\openxyos\.provision-exit" oxProvReadLive + IfFileExists "$TEMP\FreeOS-openxyos-provision.exit" oxProvReadTemp + DetailPrint "$(OPENXYOS_PROVISION_TIMEOUT)" + SetErrorLevel 68 + Goto oxProvFail + oxProvSleep: + Sleep 1000 + Goto oxProvWait + oxProvReadLive: + FileOpen $0 "$R6\FreeOS\openxyos\.provision-exit" r + FileRead $0 $1 + FileClose $0 + Goto oxProvGotCode + oxProvReadTemp: + FileOpen $0 "$TEMP\FreeOS-openxyos-provision.exit" r + FileRead $0 $1 + FileClose $0 + oxProvGotCode: + ${TrimNewLines} $1 $1 + DetailPrint "$(OPENXYOS_PROVISION_CODE)$1" + IntCmp $1 0 oxProvOk oxProvFailCode oxProvFailCode + oxProvFailCode: + ${If} $1 == 2 + ${OrIf} $1 == 3 + ${OrIf} $1 == 5 + ${OrIf} $1 == 6 + ${OrIf} $1 == 7 + SetErrorLevel 67 + ${Else} + SetErrorLevel 68 + ${EndIf} + Goto oxProvFail + oxProvOk: + IfFileExists "$R6\FreeOS\openxyos\.install-ready" oxProvReady + DetailPrint "$(OPENXYOS_PROVISION_FAIL)" + SetErrorLevel 68 + Goto oxProvFail + oxProvReady: + DetailPrint "$(OPENXYOS_PROVISION_OK)" + Return + oxProvTimeout: + DetailPrint "$(OPENXYOS_PROVISION_TIMEOUT)" + SetErrorLevel 68 + oxProvFail: + DetailPrint "$(OPENXYOS_PROVISION_FAIL)" + IfSilent oxProvSilent oxProvLoud + oxProvSilent: + Abort + oxProvLoud: + MessageBox MB_OK|MB_ICONSTOP "$(OPENXYOS_PROVISION_FAIL)" + Abort FunctionEnd Function LaunchFreeOS diff --git a/desktop/src/build/windows/nsis/wails_tools.nsh b/desktop/src/build/windows/nsis/wails_tools.nsh index 3c37f9fc..752e5bce 100644 --- a/desktop/src/build/windows/nsis/wails_tools.nsh +++ b/desktop/src/build/windows/nsis/wails_tools.nsh @@ -131,7 +131,7 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}" ${EndIf} !endif - !insertmacro wails.provisionOpenXYOS + !insertmacro wails.shipOpenXYOSPayload !macroend # Resolve the installing user's LocalAppData. After SetShellVarContext all, @@ -145,211 +145,29 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" ${EndIf} !macroend -# Ship the prebuilt openXYOS FE+BE and expand it during Setup into the -# writable live root (%LOCALAPPDATA%\FreeOS\openxyos). Program Files is -# read-only for a later unelevated FreeOS.exe, so the admin installer -# writes LocalAppData now — first app start must not copy/unpack. -# -# $INSTDIR\openxyos and $INSTDIR\openxyos-runtime stay as sealed backups -# (heal other Windows users / portable). Do NOT unzip via PowerShell with -# NSIS ''$INSTDIR'' quoting: "Program Files" splits the command. -# Write a .cmd (paths expanded at install time) and use Windows 10+ tar.exe. -!macro wails.provisionOpenXYOS - !insertmacro wails.userLocalAppData - SetDetailsPrint both - DetailPrint "$(OPENXYOS_WORKDIR)" - CreateDirectory "$INSTDIR\openxyos" - CreateDirectory "$INSTDIR\openxyos-runtime" - CreateDirectory "$R6\FreeOS\openxyos" +# Copy the sealed openXYOS zip + the independent provisioner next to +# FreeOS.exe. Do not extract, start Node, or FileWrite .cmd/.ps1 here — +# that is openxyos-provision.ps1 (same reliability as installing openXYOS +# alone). $INSTDIR\openxyos-runtime.zip is the Program Files backup. +!macro wails.shipOpenXYOSPayload DetailPrint "$(OPENXYOS_COPY_ZIP)" File "/oname=openxyos-runtime.zip" "${OPENXYOS_RUNTIME_ZIP}" - DetailPrint "$(OPENXYOS_EXTRACT)" - InitPluginsDir - FileOpen $0 "$PLUGINSDIR\extract-openxyos.cmd" w - FileWrite $0 `@echo off$\r$\n` - FileWrite $0 `setlocal EnableExtensions$\r$\n` - FileWrite $0 `set "TAR=$SYSDIR\tar.exe"$\r$\n` - FileWrite $0 `set "LIVE=$R6\FreeOS\openxyos"$\r$\n` - FileWrite $0 `set "ZIP=$INSTDIR\openxyos-runtime.zip"$\r$\n` - FileWrite $0 `if not exist "%ZIP%" exit /b 2$\r$\n` - FileWrite $0 `if not exist "%TAR%" exit /b 5$\r$\n` - FileWrite $0 `if not exist "%LIVE%" mkdir "%LIVE%"$\r$\n` - FileWrite $0 `if not exist "$INSTDIR\openxyos-runtime" mkdir "$INSTDIR\openxyos-runtime"$\r$\n` - FileWrite $0 `if not exist "$INSTDIR\openxyos" mkdir "$INSTDIR\openxyos"$\r$\n` - FileWrite $0 `"%TAR%" -xf "%ZIP%" -C "%LIVE%"$\r$\n` - FileWrite $0 `if errorlevel 1 exit /b 3$\r$\n` - FileWrite $0 `"%TAR%" -xf "%ZIP%" -C "$INSTDIR\openxyos-runtime"$\r$\n` - FileWrite $0 `if errorlevel 1 exit /b 4$\r$\n` - FileWrite $0 `"%TAR%" -xf "%ZIP%" -C "$INSTDIR\openxyos"$\r$\n` - FileWrite $0 `if errorlevel 1 exit /b 8$\r$\n` - FileWrite $0 `if exist "%LIVE%\node\node.exe" goto liveNodeOk$\r$\n` - FileWrite $0 `if exist "$INSTDIR\openxyos-runtime\node\node.exe" xcopy /E /I /Y "$INSTDIR\openxyos-runtime\*" "%LIVE%\" >nul$\r$\n` - # cmd.exe labels require a leading colon. "liveNodeOk:" is not a goto target. - FileWrite $0 `:liveNodeOk$\r$\n` - FileWrite $0 `if not exist "%LIVE%\node\node.exe" exit /b 6$\r$\n` - FileWrite $0 `if not exist "%LIVE%\openxyos\dist\index.html" if not exist "%LIVE%\dist\index.html" exit /b 7$\r$\n` - FileWrite $0 `if defined USERNAME icacls "$R6\FreeOS" /grant "%USERNAME%:(OI)(CI)M" /T /C /Q >nul 2>&1$\r$\n` - FileWrite $0 `exit /b 0$\r$\n` - FileClose $0 - nsExec::ExecToLog '"$PLUGINSDIR\extract-openxyos.cmd"' - Pop $0 - DetailPrint "$(OPENXYOS_EXTRACT_CODE)$0" - !insertmacro wails.requireOpenXYOSLayout - FileOpen $0 "$R6\FreeOS\openxyos\README.txt" w - FileWrite $0 "FreeOS local openXYOS environment$\r$\n" - FileWrite $0 "Live workdir (writable): $R6\FreeOS\openxyos$\r$\n" - FileWrite $0 "Install backup: $INSTDIR\openxyos$\r$\n" - FileWrite $0 "Sealed backup: $INSTDIR\openxyos-runtime$\r$\n" - FileWrite $0 "URL: http://127.0.0.1:3780$\r$\n" - FileClose $0 - FileOpen $0 "$INSTDIR\openxyos\README.txt" w - FileWrite $0 "FreeOS local openXYOS environment$\r$\n" - FileWrite $0 "Live workdir: $R6\FreeOS\openxyos$\r$\n" - 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 - # Stamp after layout + autostart, before livez wait. Probe must not Abort - # when node/dist exist; keep this write reachable on a slow sidecar. - 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 - !insertmacro wails.probeOpenXYOS - SetDetailsPrint listonly + File "/oname=openxyos-provision.ps1" "..\openxyos-provision.ps1" + File "/oname=openxyos-provision.cmd" "..\openxyos-provision.cmd" + File "/oname=start-sidecar.ps1" "..\start-sidecar.ps1" + File "/oname=start-sidecar.cmd" "..\start-sidecar.cmd" !macroend -# Live root is %LOCALAPPDATA%\FreeOS\openxyos ($R6). Missing node.exe or -# dist\index.html aborts setup — never finish with a README-only tree. -!macro wails.requireOpenXYOSLayout - IfFileExists "$R6\FreeOS\openxyos\node\node.exe" 0 openxyosTryStaged - IfFileExists "$R6\FreeOS\openxyos\openxyos\dist\index.html" openxyosLiveOk openxyosTryFlat - openxyosTryFlat: - IfFileExists "$R6\FreeOS\openxyos\dist\index.html" openxyosLiveOk openxyosTryStaged - openxyosLiveOk: - DetailPrint "$(OPENXYOS_FE_OK)" - DetailPrint "$(OPENXYOS_NODE_OK)" - Goto openxyosLayoutDone - openxyosTryStaged: - IfFileExists "$INSTDIR\openxyos-runtime\node\node.exe" 0 openxyosTryInstHeal - IfFileExists "$INSTDIR\openxyos-runtime\openxyos\dist\index.html" 0 openxyosTryStagedFlat - Goto openxyosCopyStaged - openxyosTryStagedFlat: - IfFileExists "$INSTDIR\openxyos-runtime\dist\index.html" 0 openxyosTryInstHeal - openxyosCopyStaged: - DetailPrint "$(OPENXYOS_STAGED_OK)" - nsExec::ExecToLog '"$SYSDIR\cmd.exe" /C xcopy /E /I /Y "$INSTDIR\openxyos-runtime\*" "$R6\FreeOS\openxyos\"' - Pop $0 - IfFileExists "$R6\FreeOS\openxyos\node\node.exe" 0 openxyosTryInstHeal - IfFileExists "$R6\FreeOS\openxyos\openxyos\dist\index.html" openxyosLiveOk openxyosTryFlatAfterCopy - openxyosTryFlatAfterCopy: - IfFileExists "$R6\FreeOS\openxyos\dist\index.html" openxyosLiveOk openxyosTryInstHeal - openxyosTryInstHeal: - IfFileExists "$INSTDIR\openxyos\node\node.exe" 0 openxyosLayoutFail - nsExec::ExecToLog '"$SYSDIR\cmd.exe" /C xcopy /E /I /Y "$INSTDIR\openxyos\*" "$R6\FreeOS\openxyos\"' - Pop $0 - IfFileExists "$R6\FreeOS\openxyos\node\node.exe" 0 openxyosLayoutFail - IfFileExists "$R6\FreeOS\openxyos\openxyos\dist\index.html" openxyosLiveOk openxyosTryFlatAfterInst - openxyosTryFlatAfterInst: - IfFileExists "$R6\FreeOS\openxyos\dist\index.html" openxyosLiveOk openxyosLayoutFail - openxyosLayoutFail: - DetailPrint "$(OPENXYOS_EXTRACT_FAIL)" - IfSilent openxyosSilentFail openxyosLoudFail - openxyosSilentFail: - SetErrorLevel 67 - Abort - openxyosLoudFail: - MessageBox MB_OK|MB_ICONSTOP "$(OPENXYOS_EXTRACT_FAIL)" - SetErrorLevel 67 - Abort - openxyosLayoutDone: -!macroend - -# 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 `$$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 = 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 = $$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 `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. -# livez timeout is a warning only: node + dist already passed -# requireOpenXYOSLayout, and HKCU Run / the LIMITED task stay registered. -!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 90; $$i++) { if (Test-Livez) { exit 0 }; if ($$i -eq 30) { schtasks.exe /Run /TN "FreeOS-openXYOS" | Out-Null }; 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\wait-openxyos.ps1"' - Pop $0 - DetailPrint "$(OPENXYOS_EXTRACT_CODE)$0" - IntCmp $0 0 openxyosProbeOk openxyosProbeWarn openxyosProbeWarn - openxyosProbeWarn: - DetailPrint "$(OPENXYOS_PROBE_WARN)" - IfSilent openxyosProbeDone openxyosProbeNote - openxyosProbeNote: - MessageBox MB_OK|MB_ICONINFORMATION "$(OPENXYOS_PROBE_WARN)" - Goto openxyosProbeDone - openxyosProbeOk: - DetailPrint "$(OPENXYOS_PROBE_OK)" - openxyosProbeDone: +# After the FreeOS shell files are in place, launch the provisioner as a +# separate unelevated process and wait for its exit marker. Failure is a +# real Setup failure (runtime + services + livez must all succeed). +!macro wails.provisionOpenXYOS + !insertmacro wails.userLocalAppData + SetDetailsPrint both + DetailPrint "$(OPENXYOS_PROVISION)" + Call LaunchOpenXYOSProvision + Call WaitOpenXYOSProvision + SetDetailsPrint listonly !macroend !macro wails.writeUninstaller diff --git a/desktop/src/build/windows/openxyos-provision.cmd b/desktop/src/build/windows/openxyos-provision.cmd new file mode 100644 index 00000000..f14e492b --- /dev/null +++ b/desktop/src/build/windows/openxyos-provision.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal EnableExtensions +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0openxyos-provision.ps1" %* +exit /b %ERRORLEVEL% diff --git a/desktop/src/build/windows/openxyos-provision.ps1 b/desktop/src/build/windows/openxyos-provision.ps1 new file mode 100644 index 00000000..05b25b00 --- /dev/null +++ b/desktop/src/build/windows/openxyos-provision.ps1 @@ -0,0 +1,324 @@ +# Independent openXYOS provisioner for the FreeOS Windows installer. +# +# Setup copies this script next to openxyos-runtime.zip and launches it as a +# separate unelevated (medium IL) process. It is the same shape as installing +# openXYOS alone: extract the payload, start FE+BE, wait until healthy. +# NSIS does not generate extract/start/wait scripts and does not FileWrite +# cmd.exe goto labels. +# +# Success (exit 0) requires ALL of: +# 1. runtime on disk (node\node.exe + dist\index.html) +# 2. backend process started at medium integrity +# 3. http://127.0.0.1:3780/api/health/livez returns HTTP < 500 +# 4. .install-ready written only after (3) +# 5. HKCU Run / ONLOGON task registered +# +# Failure is a real provision failure. Do not write .install-ready. Do not +# treat a livez miss as "shell OK". +# +# Exit codes (ASCII line in .provision-exit and %TEMP%\FreeOS-openxyos-provision.exit): +# 0 success +# 2 zip missing +# 3 tar extract failed +# 5 tar.exe missing +# 6 node.exe missing after extract +# 7 dist\index.html missing after extract +# 10 Node failed to start +# 12 process started but livez never became healthy +# 15 live directory not writable + +[CmdletBinding()] +param( + [string]$Zip = '', + [string]$Live = '', + [int]$LivezTimeoutSec = 90 +) + +$ErrorActionPreference = 'Stop' +$script:exitCode = 15 +$script:stage = 'init' + +function Get-ScriptDir { + if ($PSScriptRoot) { return $PSScriptRoot } + return (Split-Path -Parent $MyInvocation.MyCommand.Path) +} + +$scriptDir = Get-ScriptDir +if (-not $Zip) { $Zip = Join-Path $scriptDir 'openxyos-runtime.zip' } +if (-not $Live) { + $base = $env:LOCALAPPDATA + if (-not $base) { $base = Join-Path $env:USERPROFILE 'AppData\Local' } + $Live = Join-Path (Join-Path $base 'FreeOS') 'openxyos' +} + +$logFile = $null +$readyFile = Join-Path $Live '.install-ready' +$runningFile = Join-Path $Live '.provision-running' +$exitFile = Join-Path $Live '.provision-exit' +$stageFile = Join-Path $Live '.provision-stage' +$altExitFile = Join-Path $env:TEMP 'FreeOS-openxyos-provision.exit' +$livez = 'http://127.0.0.1:3780/api/health/livez' +$tar = Join-Path $env:SystemRoot 'System32\tar.exe' + +function Write-ExitMarker { + param([int]$Code) + $text = [string]$Code + foreach ($path in @($exitFile, $altExitFile)) { + try { + $dir = Split-Path -Parent $path + if ($dir -and -not (Test-Path -LiteralPath $dir)) { + New-Item -ItemType Directory -Force -Path $dir | Out-Null + } + Set-Content -LiteralPath $path -Value $text -Encoding ASCII + } catch { + } + } +} + +function Write-Log { + param([string]$Message) + $line = '[{0}] [{1}] {2}' -f (Get-Date -Format 'o'), $script:stage, $Message + Write-Host $line + if ($logFile) { + try { Add-Content -LiteralPath $logFile -Value $line -Encoding ASCII } catch {} + } +} + +function Set-Stage { + param([string]$Name) + $script:stage = $Name + if ($stageFile) { + try { Set-Content -LiteralPath $stageFile -Value $Name -Encoding ASCII } catch {} + } +} + +function Test-Livez { + try { + $r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 -Uri $livez + return ($r.StatusCode -lt 500) + } catch { + return $false + } +} + +function Resolve-AppDir { + param([string]$Root) + $nested = Join-Path $Root 'openxyos' + if (Test-Path -LiteralPath (Join-Path $nested 'dist\index.html')) { return $nested } + if (Test-Path -LiteralPath (Join-Path $Root 'dist\index.html')) { return $Root } + return $nested +} + +function Test-Layout { + param([string]$Root) + $node = Join-Path $Root 'node\node.exe' + if (-not (Test-Path -LiteralPath $node)) { return $false } + $app = Resolve-AppDir $Root + return (Test-Path -LiteralPath (Join-Path $app 'dist\index.html')) +} + +function Repair-Layout { + param([string]$Root) + if (Test-Layout $Root) { return } + $nodeDirect = Join-Path $Root 'node\node.exe' + if (Test-Path -LiteralPath $nodeDirect) { return } + Get-ChildItem -LiteralPath $Root -Directory -ErrorAction SilentlyContinue | ForEach-Object { + $candidate = $_.FullName + if (Test-Path -LiteralPath (Join-Path $candidate 'node\node.exe')) { + Get-ChildItem -LiteralPath $candidate | ForEach-Object { + $dest = Join-Path $Root $_.Name + if (Test-Path -LiteralPath $dest) { return } + Move-Item -LiteralPath $_.FullName -Destination $dest -Force + } + } + } +} + +function Copy-StartHelpers { + param([string]$Root) + $srcPs1 = Join-Path $scriptDir 'start-sidecar.ps1' + $destPs1 = Join-Path $Root 'start-sidecar.ps1' + if (Test-Path -LiteralPath $srcPs1) { + Copy-Item -LiteralPath $srcPs1 -Destination $destPs1 -Force + } + $srcCmd = Join-Path $scriptDir 'start-sidecar.cmd' + $destCmd = Join-Path $Root 'start-sidecar.cmd' + if (Test-Path -LiteralPath $srcCmd) { + Copy-Item -LiteralPath $srcCmd -Destination $destCmd -Force + } else { + Set-Content -LiteralPath $destCmd -Value "@echo off`r`npowershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"%~dp0start-sidecar.ps1`"`r`n" -Encoding ASCII + } +} + +function Register-Autostart { + param([string]$Root) + $ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $helper = Join-Path $Root 'start-sidecar.ps1' + $cmd = '"{0}" -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "{1}"' -f $ps, $helper + $runKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' + if (-not (Test-Path -LiteralPath $runKey)) { + New-Item -Path $runKey -Force | Out-Null + } + Set-ItemProperty -Path $runKey -Name 'FreeOS-openXYOS' -Value $cmd + $tr = '"{0}" -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "{1}"' -f $ps, $helper + & schtasks.exe /Create /TN 'FreeOS-openXYOS' /SC ONLOGON /RL LIMITED /F /TR $tr | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Log "schtasks ONLOGON returned $LASTEXITCODE (HKCU Run is registered)" + } +} + +function Install-ReadyMarker { + param([string]$Root) + $lines = @( + "live=$Root" + "url=$livez" + 'autostart=hkcu-run:FreeOS-openXYOS' + 'provisioner=openxyos-provision.ps1' + 'status=healthy' + ) + Set-Content -LiteralPath (Join-Path $Root '.install-ready') -Value $lines -Encoding ASCII +} + +function Start-SidecarHelper { + param([string]$Root) + $helper = Join-Path $Root 'start-sidecar.ps1' + if (-not (Test-Path -LiteralPath $helper)) { + throw "start-sidecar.ps1 missing under $Root" + } + $ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $p = Start-Process -FilePath $ps -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $helper) -Wait -PassThru -WindowStyle Hidden + return $p.ExitCode +} + +function Wait-Livez { + param([int]$Seconds) + for ($i = 0; $i -lt $Seconds; $i++) { + if (Test-Livez) { return $true } + Start-Sleep -Seconds 1 + } + return $false +} + +function Write-Readme { + param([string]$Root) + $body = @( + 'FreeOS local openXYOS environment' + "Live workdir (writable): $Root" + 'Sealed payload: openxyos-runtime.zip next to the installer copy of this script' + "URL: $livez" + 'Provisioned by openxyos-provision.ps1 (independent of the FreeOS shell copy).' + ) + Set-Content -LiteralPath (Join-Path $Root 'README.txt') -Value $body -Encoding ASCII +} + +try { + New-Item -ItemType Directory -Force -Path $Live | Out-Null + $logFile = Join-Path $Live 'provision.log' + Set-Content -LiteralPath $runningFile -Value '1' -Encoding ASCII + if (Test-Path -LiteralPath $readyFile) { + Remove-Item -LiteralPath $readyFile -Force + } + Write-ExitMarker -Code 15 + Set-Stage 'layout' + Write-Log "live=$Live zip=$Zip" + + if (Test-Layout $Live) { + Write-Log 'runtime already on disk; skipping extract' + } else { + Set-Stage 'extract' + if (-not (Test-Path -LiteralPath $Zip)) { + Write-Log "sealed zip missing: $Zip" + $script:exitCode = 2 + throw "zip missing" + } + if (-not (Test-Path -LiteralPath $tar)) { + Write-Log "tar.exe missing: $tar (Windows 10+ required; Expand-Archive is not used)" + $script:exitCode = 5 + throw "tar.exe missing" + } + Write-Log "extracting with $tar (not Expand-Archive)" + & $tar -xf $Zip -C $Live + if ($LASTEXITCODE -ne 0) { + Write-Log "tar.exe exit $LASTEXITCODE" + $script:exitCode = 3 + throw "extract failed" + } + Repair-Layout $Live + if (-not (Test-Path -LiteralPath (Join-Path $Live 'node\node.exe'))) { + Write-Log 'node\node.exe missing after extract (start never reached; not a port-3780 problem)' + $script:exitCode = 6 + throw "node missing" + } + if (-not (Test-Layout $Live)) { + Write-Log 'dist\index.html missing after extract (start never reached; not a port-3780 problem)' + $script:exitCode = 7 + throw "frontend missing" + } + Write-Log 'runtime layout ok' + } + + Set-Stage 'helpers' + Copy-StartHelpers $Live + Write-Readme $Live + + Set-Stage 'autostart' + try { + Register-Autostart $Live + Write-Log 'registered HKCU Run FreeOS-openXYOS and ONLOGON task' + } catch { + Write-Log "autostart registration failed: $($_.Exception.Message)" + throw + } + + Set-Stage 'start' + if (Test-Livez) { + Write-Log 'livez already healthy' + } else { + $startCode = Start-SidecarHelper $Live + if ($startCode -eq 6) { + Write-Log 'start-sidecar: node.exe missing (not a livez/port failure)' + $script:exitCode = 6 + throw "node missing at start" + } + if ($startCode -eq 7) { + Write-Log 'start-sidecar: dist\index.html missing (not a livez/port failure)' + $script:exitCode = 7 + throw "frontend missing at start" + } + if ($startCode -ne 0) { + Write-Log "start-sidecar failed with exit $startCode (backend did not start; do not blame port 3780 first)" + $script:exitCode = 10 + throw "start failed" + } + Write-Log "started Node; waiting up to ${LivezTimeoutSec}s for $livez" + Set-Stage 'livez' + if (-not (Wait-Livez -Seconds $LivezTimeoutSec)) { + Write-Log 'retry start once after livez miss' + $null = Start-SidecarHelper $Live + if (-not (Wait-Livez -Seconds 30)) { + Write-Log "backend start was attempted but $livez never became healthy; see this log" + $script:exitCode = 12 + throw "livez timeout" + } + } + Write-Log 'livez ok' + } + + Set-Stage 'ready' + Install-ReadyMarker $Live + $script:exitCode = 0 + Write-Log 'provision succeeded (runtime + services + livez + .install-ready)' +} catch { + Write-Log ("provision failed: " + $_.Exception.Message) + if ($script:exitCode -eq 0) { $script:exitCode = 1 } + if (Test-Path -LiteralPath $readyFile) { + Remove-Item -LiteralPath $readyFile -Force -ErrorAction SilentlyContinue + } +} finally { + Set-Stage 'done' + Write-ExitMarker -Code $script:exitCode + if (Test-Path -LiteralPath $runningFile) { + Remove-Item -LiteralPath $runningFile -Force -ErrorAction SilentlyContinue + } + exit $script:exitCode +} diff --git a/desktop/src/build/windows/start-sidecar.cmd b/desktop/src/build/windows/start-sidecar.cmd new file mode 100644 index 00000000..328b1302 --- /dev/null +++ b/desktop/src/build/windows/start-sidecar.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal EnableExtensions +powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "%~dp0start-sidecar.ps1" +exit /b %ERRORLEVEL% diff --git a/desktop/src/build/windows/start-sidecar.ps1 b/desktop/src/build/windows/start-sidecar.ps1 new file mode 100644 index 00000000..d05a7711 --- /dev/null +++ b/desktop/src/build/windows/start-sidecar.ps1 @@ -0,0 +1,101 @@ +# User-level openXYOS start helper (medium integrity). +# Copied to %LOCALAPPDATA%\FreeOS\openxyos by openxyos-provision.ps1. +# HKCU Run / the ONLOGON task invoke this file. It starts Node and returns; +# it does not write .install-ready (that is the provisioner's job after livez). +# +# Exit codes: +# 0 already healthy, or Node was started +# 6 bundled node.exe missing +# 7 dist\index.html missing + +$ErrorActionPreference = 'Continue' +$live = Split-Path -Parent $MyInvocation.MyCommand.Path +$node = Join-Path $live 'node\node.exe' +$app = Join-Path $live 'openxyos' +$nestedFe = Join-Path $app 'dist\index.html' +$flatFe = Join-Path $live 'dist\index.html' +if (-not (Test-Path -LiteralPath $nestedFe)) { + if (Test-Path -LiteralPath $flatFe) { + $app = $live + } +} +$livez = 'http://127.0.0.1:3780/api/health/livez' + +function Test-Livez { + try { + $r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 -Uri $livez + return ($r.StatusCode -lt 500) + } catch { + return $false + } +} + +if (Test-Livez) { + exit 0 +} +if (-not (Test-Path -LiteralPath $node)) { + Write-Error "start-sidecar: bundled Node missing at $node" + exit 6 +} +$fe = Join-Path $app 'dist\index.html' +if (-not (Test-Path -LiteralPath $fe)) { + Write-Error "start-sidecar: frontend missing at $fe" + exit 7 +} + +$home = $env:FREEOS_HOME +if (-not $home) { + $home = Join-Path $env:USERPROFILE '.freeos' +} +$data = Join-Path $home 'org-os' +New-Item -ItemType Directory -Force -Path $data | Out-Null +$secretFile = Join-Path $data 'sidecar.env' +$jwt = '' +$cookie = '' +$ingest = '' +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] } + } +} +if (-not $jwt) { $jwt = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N') } +if (-not $cookie) { $cookie = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N') } +if (-not $ingest) { $ingest = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N') } +@( + "JWT_SECRET=$jwt" + "COOKIE_SECRET=$cookie" + "FREEOS_INGEST_TOKEN=$ingest" +) | Set-Content -LiteralPath $secretFile -Encoding ASCII + +$env:NODE_ENV = 'production' +$env:PORT = '3780' +$env:DB_DIALECT = 'sqlite' +$env:DATABASE_PATH = Join-Path $data 'xiongyuan.db' +$env:AIR_GAP_MODE = 'true' +$env:SEED_DEMO_DATA = 'false' +$env:ALLOW_PUBLIC_REGISTRATION = 'false' +$env:JWT_SECRET = $jwt +$env:COOKIE_SECRET = $cookie +$env:FREEOS_INGEST_TOKEN = $ingest +$env:CORS_ORIGIN = 'http://127.0.0.1:8088,http://localhost:8088,http://127.0.0.1:18900,http://localhost:18900' +$env:FREEOS_HOME = $home +$env:OCTOP_HOME = $home +$env:FREEOS_ORG_SIDECAR_PORT = '3780' +$env:FREEOS_OPENXYOS_HOME = $live + +$compiled = Join-Path $app 'backend-dist\server.js' +if (Test-Path -LiteralPath $compiled) { + $argv = @('backend-dist/server.js') +} else { + $argv = @('--import', 'tsx', 'backend/server.ts') +} + +try { + Start-Process -FilePath $node -ArgumentList $argv -WorkingDirectory $app -WindowStyle Hidden +} catch { + Write-Error "start-sidecar: failed to start Node: $($_.Exception.Message)" + exit 10 +} +exit 0 diff --git a/desktop/src/process_sidecar.go b/desktop/src/process_sidecar.go index 13ec37fe..7cde8321 100644 --- a/desktop/src/process_sidecar.go +++ b/desktop/src/process_sidecar.go @@ -320,8 +320,8 @@ func startOrgSidecar(root string, dashboardPort int) (*exec.Cmd, error) { func provisionOpenXYOS(portableRoot string, locale Locale, status func(string)) (string, error) { dest := openxyosUserWorkDir() if sidecarBundleReady(dest) { - // Windows NSIS writes this tree during Setup. First launch must - // not copy or unpack when the install-time live root is complete. + // Windows install-time provisioner writes this tree. First launch + // must not copy or unpack when the live root is already complete. log.Printf("openXYOS workdir already complete at %s", dest) return dest, nil } diff --git a/src/octop/modules/org_os/sidecar_launch.py b/src/octop/modules/org_os/sidecar_launch.py index 3cf3020a..061aa1c2 100644 --- a/src/octop/modules/org_os/sidecar_launch.py +++ b/src/octop/modules/org_os/sidecar_launch.py @@ -228,7 +228,7 @@ def add(path: Path | None) -> None: def sidecar_install_ready() -> bool: - """True when Setup wrote ``.install-ready`` on a live openXYOS tree.""" + """True when the install-time provisioner wrote ``.install-ready`` after livez.""" for root in sidecar_candidate_roots(): if (root / ".install-ready").is_file(): return True diff --git a/tests/unit/desktop/test_nsis_uninstall.py b/tests/unit/desktop/test_nsis_uninstall.py index 0db060b0..b98137ec 100644 --- a/tests/unit/desktop/test_nsis_uninstall.py +++ b/tests/unit/desktop/test_nsis_uninstall.py @@ -141,16 +141,15 @@ def test_packaged_windows_entry_is_freeos_exe() -> None: def test_nsis_provisions_openxyos_runtime() -> None: nsi = NSI.read_text(encoding="utf-8-sig") nsh = NSH.read_text(encoding="utf-8") - assert "!insertmacro wails.provisionOpenXYOS" in nsh + assert "!insertmacro wails.shipOpenXYOSPayload" in nsh + assert "!insertmacro wails.provisionOpenXYOS" in nsi assert 'File "/oname=openxyos-runtime.zip"' in nsh - assert r"$INSTDIR\openxyos" in nsh - assert r"$INSTDIR\openxyos-runtime" in nsh - assert r"$R6\FreeOS\openxyos" in nsh + assert 'File "/oname=openxyos-provision.ps1"' in nsh + assert 'File "/oname=start-sidecar.ps1"' in nsh assert "ReadEnvStr $R6 LOCALAPPDATA" in nsh - assert "http://127.0.0.1:3780" in nsh - assert "LangString OPENXYOS_WORKDIR ${LANG_SIMPCHINESE}" in nsi - assert "创建 openXYOS 工作目录" in nsi - assert "openXYOS 运行包" in nsi + assert "LangString OPENXYOS_PROVISION ${LANG_SIMPCHINESE}" in nsi + assert "独立的 openXYOS 预配置进程" in nsi + assert "openXYOS 密封运行包" in nsi task = (REPO / "desktop" / "src" / "build" / "windows" / "Taskfile.yml").read_text( encoding="utf-8" ) @@ -201,45 +200,35 @@ def test_nsis_filewrite_is_exactly_two_args() -> None: assert "(`$" not in stripped and ")`$" not in stripped, stripped -def test_nsis_extracts_runtime_with_quoted_paths_and_aborts_if_incomplete() -> None: - """Program Files spaces must not yield a README-only $INSTDIR\\openxyos.""" +def test_nsis_ships_sealed_zip_and_independent_provisioner() -> None: + """NSIS copies payload + provisioner; it does not invent extract/start glue.""" nsi = NSI.read_text(encoding="utf-8-sig") nsh = NSH.read_text(encoding="utf-8") - provision = nsh[ - nsh.index("!macro wails.provisionOpenXYOS") : nsh.index( - "!macro wails.requireOpenXYOSLayout" - ) - ] - require = nsh[nsh.index("!macro wails.requireOpenXYOSLayout") :] + ship = nsh[nsh.index("!macro wails.shipOpenXYOSPayload") : nsh.index("!macro wails.provisionOpenXYOS")] + launch = nsh[nsh.index("!macro wails.provisionOpenXYOS") : nsh.index("!macro wails.writeUninstaller")] + install = nsi[nsi.index("Section\n") : nsi.index('Section "uninstall"')] assert "Expand-Archive" not in nsh - assert "nsExec::ExecToLog" in provision and "tar.exe" in provision - assert "extract-openxyos.cmd" in provision - assert "tar.exe" in provision - assert r"$INSTDIR\openxyos-runtime.zip" in provision - assert "nsExec::ExecToLog" in provision - assert "Pop $0" in provision - assert r"$R6\FreeOS\openxyos\node\node.exe" in require - assert r"$R6\FreeOS\openxyos\openxyos\dist\index.html" in require - assert "Abort" in require - assert "SetErrorLevel 67" in require - assert "OPENXYOS_EXTRACT_FAIL" in require - assert "LangString OPENXYOS_EXTRACT_FAIL ${LANG_SIMPCHINESE}" in nsi - assert "未能把完整的 openXYOS" in nsi - assert "openxyos-runtime.zip missing" in nsh - assert "!error" in nsh - assert "wait-openxyos.ps1" in nsh + assert "extract-openxyos.cmd" not in nsh + assert "wait-openxyos.ps1" not in nsh assert "probe-openxyos.ps1" not in nsh - assert "/api/health/livez" in nsh - assert "LangString OPENXYOS_PROBE_WARN ${LANG_SIMPCHINESE}" in nsi - assert "安装将继续" in nsi - assert ".install-ready" in provision - assert provision.index(".install-ready") < provision.index("!insertmacro wails.probeOpenXYOS") - assert provision.index("!insertmacro wails.requireOpenXYOSLayout") < provision.index( - ".install-ready" + assert "!macro wails.requireOpenXYOSLayout" not in nsh + assert "!macro wails.writeOpenXYOSAutostart" not in nsh + assert "!macro wails.probeOpenXYOS" not in nsh + assert "FileWrite" not in ship + assert "FileWrite" not in launch + assert 'File "/oname=openxyos-runtime.zip"' in ship + assert 'File "/oname=openxyos-provision.ps1"' in ship + assert "Call LaunchOpenXYOSProvision" in launch + assert "Call WaitOpenXYOSProvision" in launch + assert install.index("!insertmacro wails.writeUninstaller") < install.index( + "!insertmacro wails.provisionOpenXYOS" ) - assert provision.index("Call PersistOpenXYOS") < provision.index(".install-ready") - assert "start-sidecar.ps1" in nsh - assert "FreeOS-openXYOS" in nsi + assert "openxyos-runtime.zip missing" in nsh + assert "!error" in nsh + assert "LangString OPENXYOS_PROVISION_FAIL ${LANG_SIMPCHINESE}" in nsi + assert "独立预配置进程" in nsi + assert "安装将继续" not in nsi + assert "OPENXYOS_PROBE_WARN" not 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 @@ -266,62 +255,40 @@ 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 _filewrite_payloads(block: str) -> list[str]: - payloads: list[str] = [] - for raw in block.splitlines(): - stripped = raw.strip() - if not stripped.startswith("FileWrite"): +def test_nsis_no_longer_embeds_cmd_goto_labels() -> None: + """The liveNodeOk FileWrite/goto class must not return.""" + nsh = NSH.read_text(encoding="utf-8") + nsi = NSI.read_text(encoding="utf-8-sig") + for blob in (nsh, nsi): + assert "goto liveNodeOk" not in blob + assert "liveNodeOk:" not in blob + assert ":liveNodeOk" not in blob + assert "extract-openxyos.cmd" not in blob + for lineno, raw in enumerate(nsh.splitlines(), start=1): + if not raw.strip().startswith("FileWrite"): continue - start = stripped.find("`") - end = stripped.rfind("`") - if start != -1 and end > start: - payloads.append(stripped[start + 1 : end].replace("$\\r$\\n", "\n")) - return payloads + payload = raw + if "goto " in payload.lower(): + raise AssertionError(f"wails_tools.nsh:{lineno}: FileWrite still embeds goto: {raw}") -def test_nsis_extract_cmd_uses_colon_prefixed_batch_labels() -> None: - """cmd.exe goto targets must be ':label'; 'label:' is not found.""" - nsh = NSH.read_text(encoding="utf-8") - provision = nsh[ - nsh.index("!macro wails.provisionOpenXYOS") : nsh.index( - "!macro wails.requireOpenXYOSLayout" - ) - ] - script = "".join(_filewrite_payloads(provision)) - assert "goto liveNodeOk" in script - assert ":liveNodeOk" in script - assert "exit /b 0" in script - assert "FileWrite $0 `liveNodeOk:" not in nsh - for line in script.splitlines(): - token = line.strip() - if token.endswith(":") and " " not in token and not token.startswith(":"): - raise AssertionError(f"cmd label missing leading colon: {token!r}") - - -def test_nsis_leaves_user_level_sidecar_running() -> None: - """#29 probed then taskkill'd Node; Organization still asked to 启动边车.""" +def test_nsis_waits_for_unelevated_provisioner_and_aborts_on_failure() -> None: + """Provisioner exit 0 + .install-ready is required; livez miss is not a warning.""" 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 "Abort" not in probe - assert "SetErrorLevel 68" not in probe - assert "wait-openxyos.ps1" in probe - assert "OPENXYOS_PROBE_WARN" in probe - assert "schtasks.exe /Run" in probe - assert "Call PersistOpenXYOS" in nsh - assert "FreeOS-openXYOS" in persist - assert "A4C6892C-3BA9-11d2-9DEA-00C04FB16162" in persist + launch = nsi[nsi.index("Function LaunchOpenXYOSProvision") : nsi.index("Function WaitOpenXYOSProvision")] + wait = nsi[nsi.index("Function WaitOpenXYOSProvision") : nsi.index("Function LaunchFreeOS")] + assert "openxyos-provision.ps1" in launch + assert "A4C6892C-3BA9-11d2-9DEA-00C04FB16162" in launch + assert "taskkill.exe" not in launch + assert "taskkill.exe" not in wait + assert ".provision-exit" in wait + assert ".install-ready" in wait + assert "Abort" in wait + assert "SetErrorLevel 67" in wait + assert "SetErrorLevel 68" in wait + assert "OPENXYOS_PROBE_WARN" not in wait + assert "安装将继续" not in nsi assert r"*\FreeOS\openxyos\*" in nsh uninstall = _uninstall_section(nsi) assert ( diff --git a/tests/unit/desktop/test_openxyos_provisioner.py b/tests/unit/desktop/test_openxyos_provisioner.py new file mode 100644 index 00000000..c18ef92e --- /dev/null +++ b/tests/unit/desktop/test_openxyos_provisioner.py @@ -0,0 +1,105 @@ +"""Independent openXYOS provisioner: success criteria and NSIS hand-off.""" + +from __future__ import annotations + +from pathlib import Path + +REPO = Path(__file__).resolve().parents[3] +WIN = REPO / "desktop" / "src" / "build" / "windows" +PROVISION = WIN / "openxyos-provision.ps1" +START = WIN / "start-sidecar.ps1" +NSH = WIN / "nsis" / "wails_tools.nsh" +NSI = WIN / "nsis" / "project.nsi" + + +# Documented success contract (must stay in sync with openxyos-provision.ps1). +SUCCESS_REQUIRES = ( + "runtime on disk (node\\node.exe + dist\\index.html)", + "backend process started at medium integrity", + "http://127.0.0.1:3780/api/health/livez returns HTTP < 500", + ".install-ready written only after livez", +) + +EXIT_CODES = { + 0: "success", + 2: "zip missing", + 3: "tar extract failed", + 5: "tar.exe missing", + 6: "node.exe missing", + 7: "dist\\index.html missing", + 10: "Node failed to start", + 12: "livez never became healthy", +} + + +def test_provisioner_files_are_shipped_not_generated() -> None: + assert PROVISION.is_file() + assert START.is_file() + assert (WIN / "openxyos-provision.cmd").is_file() + assert (WIN / "start-sidecar.cmd").is_file() + nsh = NSH.read_text(encoding="utf-8") + assert 'File "/oname=openxyos-provision.ps1"' in nsh + assert 'File "/oname=start-sidecar.ps1"' in nsh + assert "FileWrite" not in nsh[nsh.index("!macro wails.shipOpenXYOSPayload") :] + + +def test_provisioner_success_criteria_are_documented() -> None: + text = PROVISION.read_text(encoding="utf-8") + assert "Success (exit 0) requires ALL of:" in text + assert ".install-ready written only after" in text + assert "Do not write .install-ready" in text + assert "treat a livez miss as" in text + for code, label in EXIT_CODES.items(): + assert str(code) in text + assert label.split()[0] in text or str(code) == "0" + assert "Expand-Archive" in text # mentioned only as forbidden + assert "Expand-Archive is not used" in text + assert "tar.exe" in text + assert "http://127.0.0.1:3780/api/health/livez" in text + assert "HKCU" in text or "FreeOS-openXYOS" in text + + +def test_provisioner_writes_ready_marker_only_after_livez() -> None: + text = PROVISION.read_text(encoding="utf-8") + ready_fn = text.index("function Install-ReadyMarker") + ready_call = text.index("Install-ReadyMarker $Live") + livez_wait = text.index("Wait-Livez") + livez_ok = text.index("livez ok") + assert ready_fn < livez_wait < livez_ok < ready_call + fail_path = text[text.index("} catch {") :] + assert "Remove-Item -LiteralPath $readyFile" in fail_path + start = START.read_text(encoding="utf-8") + assert ".install-ready" not in start or "does not write .install-ready" in start + assert "Start-Process" in start + assert "exit 6" in start + assert "exit 7" in start + + +def test_provisioner_does_not_use_expand_archive() -> None: + for path in (PROVISION, START): + body = path.read_text(encoding="utf-8") + assert "Expand-Archive" not in body or "not Expand-Archive" in body + assert not any( + line.strip().startswith("Expand-Archive") + or "Expand-Archive " in line + for line in body.splitlines() + if "not Expand-Archive" not in line and "is not used" not in line + ) + + +def test_provisioner_distinguishes_start_failure_from_livez() -> None: + text = PROVISION.read_text(encoding="utf-8") + assert "not a livez/port failure" in text or "not a port-3780 problem" in text + assert "$script:exitCode = 10" in text + assert "$script:exitCode = 12" in text + nsi = NSI.read_text(encoding="utf-8-sig") + assert "不要只把原因归为「检查 3780 端口」" in nsi + assert "do not treat this as a generic port-3780 check" in nsi + + +def test_start_sidecar_is_user_level_helper() -> None: + text = START.read_text(encoding="utf-8") + assert "WindowStyle Hidden" in text + assert "FREEOS_INGEST_TOKEN" in text + assert "backend-dist/server.js" in text + assert "taskkill" not in text.lower() From dce921d1683cf54bee8cef936247bc02c81d7897 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 13:22:53 +0000 Subject: [PATCH 2/4] Format desktop provisioner unit tests after make all. --- tests/unit/desktop/test_nsis_uninstall.py | 12 +++++++++--- tests/unit/desktop/test_openxyos_provisioner.py | 3 +-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/unit/desktop/test_nsis_uninstall.py b/tests/unit/desktop/test_nsis_uninstall.py index b98137ec..11dffc3c 100644 --- a/tests/unit/desktop/test_nsis_uninstall.py +++ b/tests/unit/desktop/test_nsis_uninstall.py @@ -204,8 +204,12 @@ def test_nsis_ships_sealed_zip_and_independent_provisioner() -> None: """NSIS copies payload + provisioner; it does not invent extract/start glue.""" nsi = NSI.read_text(encoding="utf-8-sig") nsh = NSH.read_text(encoding="utf-8") - ship = nsh[nsh.index("!macro wails.shipOpenXYOSPayload") : nsh.index("!macro wails.provisionOpenXYOS")] - launch = nsh[nsh.index("!macro wails.provisionOpenXYOS") : nsh.index("!macro wails.writeUninstaller")] + ship = nsh[ + nsh.index("!macro wails.shipOpenXYOSPayload") : nsh.index("!macro wails.provisionOpenXYOS") + ] + launch = nsh[ + nsh.index("!macro wails.provisionOpenXYOS") : nsh.index("!macro wails.writeUninstaller") + ] install = nsi[nsi.index("Section\n") : nsi.index('Section "uninstall"')] assert "Expand-Archive" not in nsh assert "extract-openxyos.cmd" not in nsh @@ -276,7 +280,9 @@ def test_nsis_waits_for_unelevated_provisioner_and_aborts_on_failure() -> None: """Provisioner exit 0 + .install-ready is required; livez miss is not a warning.""" nsi = NSI.read_text(encoding="utf-8-sig") nsh = NSH.read_text(encoding="utf-8") - launch = nsi[nsi.index("Function LaunchOpenXYOSProvision") : nsi.index("Function WaitOpenXYOSProvision")] + launch = nsi[ + nsi.index("Function LaunchOpenXYOSProvision") : nsi.index("Function WaitOpenXYOSProvision") + ] wait = nsi[nsi.index("Function WaitOpenXYOSProvision") : nsi.index("Function LaunchFreeOS")] assert "openxyos-provision.ps1" in launch assert "A4C6892C-3BA9-11d2-9DEA-00C04FB16162" in launch diff --git a/tests/unit/desktop/test_openxyos_provisioner.py b/tests/unit/desktop/test_openxyos_provisioner.py index c18ef92e..2afcc1ed 100644 --- a/tests/unit/desktop/test_openxyos_provisioner.py +++ b/tests/unit/desktop/test_openxyos_provisioner.py @@ -80,8 +80,7 @@ def test_provisioner_does_not_use_expand_archive() -> None: body = path.read_text(encoding="utf-8") assert "Expand-Archive" not in body or "not Expand-Archive" in body assert not any( - line.strip().startswith("Expand-Archive") - or "Expand-Archive " in line + line.strip().startswith("Expand-Archive") or "Expand-Archive " in line for line in body.splitlines() if "not Expand-Archive" not in line and "is not used" not in line ) From e6fc9185f5fe1db4371d48a0fd9ac85c0f198a2d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 13:58:04 +0000 Subject: [PATCH 3/4] Fix NSIS compile: strip provisioner exit codes without TrimNewLines. makensis on windows-amd64/arm64 rejected ${TrimNewLines} as an unknown command. Wait for the sentinel with a small CR/LF strip instead, and write the exit marker without a trailing newline. --- desktop/src/build/windows/nsis/project.nsi | 12 +++++++++++- desktop/src/build/windows/openxyos-provision.ps1 | 2 +- tests/unit/desktop/test_nsis_uninstall.py | 2 ++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/desktop/src/build/windows/nsis/project.nsi b/desktop/src/build/windows/nsis/project.nsi index 670d255b..eb8cf65b 100644 --- a/desktop/src/build/windows/nsis/project.nsi +++ b/desktop/src/build/windows/nsis/project.nsi @@ -211,7 +211,17 @@ Function WaitOpenXYOSProvision FileRead $0 $1 FileClose $0 oxProvGotCode: - ${TrimNewLines} $1 $1 + ; FileRead keeps CR/LF. FileFunc TrimNewLines is not a command unless + ; the define is installed; strip trailing whitespace here instead. + oxProvTrim: + StrCpy $2 $1 1 -1 + StrCmp $2 $\r oxProvStrip 0 + StrCmp $2 $\n oxProvStrip 0 + StrCmp $2 " " oxProvStrip oxProvTrimDone + oxProvStrip: + StrCpy $1 $1 -1 + Goto oxProvTrim + oxProvTrimDone: DetailPrint "$(OPENXYOS_PROVISION_CODE)$1" IntCmp $1 0 oxProvOk oxProvFailCode oxProvFailCode oxProvFailCode: diff --git a/desktop/src/build/windows/openxyos-provision.ps1 b/desktop/src/build/windows/openxyos-provision.ps1 index 05b25b00..f9731678 100644 --- a/desktop/src/build/windows/openxyos-provision.ps1 +++ b/desktop/src/build/windows/openxyos-provision.ps1 @@ -69,7 +69,7 @@ function Write-ExitMarker { if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } - Set-Content -LiteralPath $path -Value $text -Encoding ASCII + [System.IO.File]::WriteAllText($path, $text) } catch { } } diff --git a/tests/unit/desktop/test_nsis_uninstall.py b/tests/unit/desktop/test_nsis_uninstall.py index 11dffc3c..58a31f7f 100644 --- a/tests/unit/desktop/test_nsis_uninstall.py +++ b/tests/unit/desktop/test_nsis_uninstall.py @@ -293,6 +293,8 @@ def test_nsis_waits_for_unelevated_provisioner_and_aborts_on_failure() -> None: assert "Abort" in wait assert "SetErrorLevel 67" in wait assert "SetErrorLevel 68" in wait + assert "${TrimNewLines}" not in nsi + assert "oxProvTrim" in wait assert "OPENXYOS_PROBE_WARN" not in wait assert "安装将继续" not in nsi assert r"*\FreeOS\openxyos\*" in nsh From 9c81d961ef61d596e3a602cc8e7526f5b2b902f9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 13:58:16 +0000 Subject: [PATCH 4/4] Assert provisioner writes exit markers with WriteAllText. --- tests/unit/desktop/test_openxyos_provisioner.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/desktop/test_openxyos_provisioner.py b/tests/unit/desktop/test_openxyos_provisioner.py index 2afcc1ed..fa719d51 100644 --- a/tests/unit/desktop/test_openxyos_provisioner.py +++ b/tests/unit/desktop/test_openxyos_provisioner.py @@ -57,6 +57,7 @@ def test_provisioner_success_criteria_are_documented() -> None: assert "tar.exe" in text assert "http://127.0.0.1:3780/api/health/livez" in text assert "HKCU" in text or "FreeOS-openXYOS" in text + assert "WriteAllText" in text def test_provisioner_writes_ready_marker_only_after_livez() -> None: