From fe5091b2baadac735410afcca08795d8fe1f0902 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 06:30:36 +0000 Subject: [PATCH 1/2] Deploy openXYOS into LocalAppData during Windows Setup. The elevated installer now extracts the prebuilt runtime with tar.exe directly into %LOCALAPPDATA%\FreeOS\openxyos (the writable live root), keeps Program Files trees as backups, and probes http://127.0.0.1:3780/api/health/livez before Setup finishes. First FreeOS start skips copy/unpack when that tree is already complete. Co-authored-by: XYAI Labs --- .github/workflows/octop-desktop.yml | 4 + CHANGELOG.md | 4 +- desktop/README.md | 45 +++--- desktop/src/build/windows/nsis/project.nsi | 33 ++-- .../src/build/windows/nsis/wails_tools.nsh | 147 ++++++++++++++---- desktop/src/main.go | 3 +- desktop/src/process_sidecar.go | 4 +- desktop/src/process_sidecar_test.go | 25 +++ tests/unit/desktop/test_nsis_uninstall.py | 20 ++- 9 files changed, 221 insertions(+), 64 deletions(-) diff --git a/.github/workflows/octop-desktop.yml b/.github/workflows/octop-desktop.yml index b30ce66d..1acd8086 100644 --- a/.github/workflows/octop-desktop.yml +++ b/.github/workflows/octop-desktop.yml @@ -224,6 +224,10 @@ jobs: echo "org-sidecar missing under ${staging}" >&2 exit 1 fi + if [[ ! -f "${staging}/org-sidecar/openxyos/dist/index.html" ]]; then + echo "openXYOS frontend missing under ${staging}/org-sidecar/openxyos/dist" >&2 + exit 1 + fi if [[ -f "${staging}/org-sidecar/node/node.exe" || -x "${staging}/org-sidecar/node/bin/node" ]]; then echo "org-sidecar Node runtime present" else diff --git a/CHANGELOG.md b/CHANGELOG.md index 875d294a..dfe2dff9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ - 组织循环时间线的是/否改为中英文本地化,不再混用 `true`/`false`。 - 边车启动会写入并传递 `FREEOS_INGEST_TOKEN`;Python 启动器同时识别 `%LOCALAPPDATA%\FreeOS\openxyos`、`$INSTDIR\openxyos` 与 portable `org-sidecar`。 -- Windows 安装包写入预构建 openXYOS 运行包(`$INSTDIR\openxyos` + `openxyos-runtime.zip`),首次启动再落到 `%LOCALAPPDATA%\FreeOS\openxyos` / `~/.freeos/openxyos`,自动拉起 `http://127.0.0.1:3780`,组织页不必再点「启动边车」。 +- Windows 安装包在 Setup 内把预构建 openXYOS 运行包解到 `%LOCALAPPDATA%\FreeOS\openxyos`(可写本机根)并完成 livez 探测;`$INSTDIR\openxyos` 仅作备份。组织页打开即可嵌入 `http://127.0.0.1:3780`,不必再点「启动边车」,也不再等首次启动拷贝。 - 项目创建用系统文件夹选择器(Wails 原生对话框,任意盘符);项目/任务/对话区默认展示实时对话列表,并提供建群/群聊入口。 - Organization OS 首页双循环:FreeOS 数据面与 openXYOS 控制面的实时状态、一键导入部门员工为同事 / 打包专家为员工 / 运行 org loop,以及边车离线时的启动恢复。 - FreeOS 官网源码:`website/`(Vite + React 静态站,正式域名 https://freeos.cnxy.tech)。视觉与章节节奏对齐 XYAI Labs(天蓝→亮光、毛玻璃、01/02/…、底座 / 系统 / 生态),文案仍是 FreeOS 产品自己的话;圆形标志 + XYAI 机器人六姿态,无 Octop 红或章鱼形象。构建产物可整包上传到 `/www/wwwroot/freeos.cnxy.tech`。 @@ -36,7 +36,7 @@ ### 修复 -- Windows 安装把预构建 openXYOS 运行包用 `tar.exe` 解到 `$INSTDIR\openxyos` 与 `$INSTDIR\openxyos-runtime`(带空格的 `Program Files` 也能解全)。缺少 `node\node.exe` 或 `dist\index.html` 时安装失败,不再留下只有 README 的工作目录。首次启动会从完整树复制到 `%LOCALAPPDATA%\FreeOS\openxyos` 并自动拉起边车。组织页「下载最新 openXYOS 源码」走系统文件夹选择器(任意盘符),不再只用手输路径。 +- Windows 安装期就把预构建 openXYOS 用 `tar.exe` 解到 **`%LOCALAPPDATA%\FreeOS\openxyos` 本机工作目录**(`Program Files` 只作只读备份:`$INSTDIR\openxyos` 与 `openxyos-runtime`)。Setup 会拉起 FE+BE 直到 `http://127.0.0.1:3780/api/health/livez` 通过再结束;缺少 `node\node.exe` / `dist\index.html` 或健康检查失败则安装失败。首次打开 FreeOS 不再从安装目录拷贝/解压。组织页「下载最新 openXYOS 源码」走系统文件夹选择器(任意盘符),不再只用手输路径。 - 桌面 / 本地会话不再被 `setup_required` 短路:`GET /setup/status` 之后仍可调用 `POST /auth/local-session`,不会误跳进服务端密码向导。 - 组织页「启动边车」会拉起捆绑的 Node + openXYOS(Windows 用 `node.exe` / `cmd /c`,不再直接 Popen `.bat`),并在右侧预览区嵌入控制台(支持全屏)。openXYOS 在 FreeOS 桌面环境下关闭 `X-Frame-Options`,避免 iframe 空白。安装 / 桌面启动时宿主与 Wails 都会确保边车常开(需 `dist/index.html` 已打包)。 - Windows 打开时不再因 WebView2 `Navigate`/`SetURL` 在 Chromium 未就绪时触发 Go panic。主窗口等 WebView 点火后再跳转;`SetURL` 失败会重试并显示 FreeOS 提示,而不是原始堆栈。设置窗口延后创建,避免两个 WebView2 同时初始化同一用户目录。用户数据目录必须可写,并设置 `WEBVIEW2_USER_DATA_FOLDER`。 diff --git a/desktop/README.md b/desktop/README.md index fd120a85..af16623e 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -24,9 +24,11 @@ Same precedence as the FreeOS CLI/server: - 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` into `$INSTDIR\openxyos` and `$INSTDIR\openxyos-runtime` - (install fails if `node` or `dist` is missing). First unelevated start copies - that tree into the workdir and starts `http://127.0.0.1:3780`. + `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, 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. - Shell prefs → `{home}/desktop-settings.json` ## Windows install finish @@ -41,16 +43,21 @@ 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 **both** `$INSTDIR\openxyos` and `$INSTDIR\openxyos-runtime`. -Setup **aborts** if `node\node.exe` or `dist\index.html` is missing after -extract — a README-only `$INSTDIR\openxyos` is not a successful install. -The durable workdir is `%LOCALAPPDATA%\FreeOS\openxyos` (and `{home}/openxyos`). -Program Files is read-only, so first start — not the elevated installer — -copies the complete tree into the user workdir (and will heal from -`openxyos-runtime` if `openxyos` was left as a stub). Organization then -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. +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 +`/api/health/livez` — a README-only tree is not a successful install. + +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 +「启动边车」 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`. @@ -92,11 +99,13 @@ would treat that tree as program files (unless it is named `User Data` / On first open the shell: -1. Extracts the bundled portable runtime (Python host + Node + openXYOS). -2. Provisions the openXYOS workdir (`%LOCALAPPDATA%\FreeOS\openxyos` or - `{home}/openxyos`) from `$INSTDIR\openxyos` or `portable/org-sidecar`. -3. Starts the openXYOS sidecar on `http://127.0.0.1:3780` and waits until - `/api/health/livez` responds (first launch allows extra time). +1. Extracts the bundled portable **Python host** (the openXYOS FE+BE tree + was already written to `%LOCALAPPDATA%\FreeOS\openxyos` during Setup). +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. 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 bdfec31e..f330efdb 100644 --- a/desktop/src/build/windows/nsis/project.nsi +++ b/desktop/src/build/windows/nsis/project.nsi @@ -52,22 +52,28 @@ ManifestDPIAware true LangString FINISH_RUN ${LANG_SIMPCHINESE} "运行 FreeOS" LangString FINISH_RUN ${LANG_ENGLISH} "Run FreeOS" -LangString OPENXYOS_WORKDIR ${LANG_SIMPCHINESE} "创建 openXYOS 工作目录(安装目录与 %LOCALAPPDATA%\\FreeOS\\openxyos)" -LangString OPENXYOS_WORKDIR ${LANG_ENGLISH} "Create the openXYOS work directory (install dir and %LOCALAPPDATA%\\FreeOS\\openxyos)" +LangString OPENXYOS_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 前端与后端到安装目录\\openxyos" -LangString OPENXYOS_EXTRACT ${LANG_ENGLISH} "Extract openXYOS frontend and backend into the install dir\\openxyos" -LangString OPENXYOS_FE_OK ${LANG_SIMPCHINESE} "已就绪:openxyos\\dist\\index.html" -LangString OPENXYOS_FE_OK ${LANG_ENGLISH} "Ready: openxyos\\dist\\index.html" +LangString OPENXYOS_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 full runtime from openxyos-runtime" +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 运行环境(需要 node\\node.exe 与 dist\\index.html)。请重新下载安装包。" -LangString OPENXYOS_EXTRACT_FAIL ${LANG_ENGLISH} "Setup failed: openXYOS runtime is incomplete (need node\\node.exe and dist\\index.html). Download the installer again." +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_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要继续卸载吗?" 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?" @@ -119,10 +125,15 @@ Section "uninstall" !insertmacro wails.setShellContext ; Processes were already confirmed + stopped in un.onInit when present. - ; Program-owned WebView2 / Wails cache — not FREEOS_HOME user data. + ; Program-owned WebView2 / Wails cache + install-time live openXYOS tree. + ; Not FREEOS_HOME / OCTOP_HOME user data. $LOCALAPPDATA after SetShellVarContext + ; all is ProgramData; also wipe the installing user's LocalAppData. + !insertmacro wails.userLocalAppData RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" RMDir /r "$LOCALAPPDATA\${PRODUCT_EXECUTABLE}.WebView2" RMDir /r "$LOCALAPPDATA\${INFO_PRODUCTNAME}" + RMDir /r "$R6\${PRODUCT_EXECUTABLE}.WebView2" + RMDir /r "$R6\${INFO_PRODUCTNAME}" Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk" diff --git a/desktop/src/build/windows/nsis/wails_tools.nsh b/desktop/src/build/windows/nsis/wails_tools.nsh index bfb73e0b..bca98fcf 100644 --- a/desktop/src/build/windows/nsis/wails_tools.nsh +++ b/desktop/src/build/windows/nsis/wails_tools.nsh @@ -134,77 +134,119 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" !insertmacro wails.provisionOpenXYOS !macroend -# Ship the prebuilt openXYOS FE+BE next to FreeOS.exe and expand it so the -# finish log is not just FreeOS.exe + shortcuts. First unelevated start also -# copies this tree into %LOCALAPPDATA%\FreeOS\openxyos (Program Files is read-only). +# Resolve the installing user's LocalAppData. After SetShellVarContext all, +# NSIS $LOCALAPPDATA is ProgramData — not the path FreeOS reads at runtime +# (LOCALAPPDATA env of the unelevated user). ReadEnvStr keeps the original +# profile so an elevated install can write the live tree the app will use. +!macro wails.userLocalAppData + ReadEnvStr $R6 LOCALAPPDATA + ${If} $R6 == "" + StrCpy $R6 "$PROFILE\AppData\Local" + ${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. # -# Do NOT call PowerShell to unzip with NSIS ''$INSTDIR'' quoting: "Program -# Files" splits the command, extract silently fails, and $INSTDIR\openxyos is -# left README-only. Write a .cmd (paths expanded at install time) and use -# Windows 10+ tar.exe, which accepts quoted paths with spaces. +# $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 "$LOCALAPPDATA\FreeOS\openxyos" + CreateDirectory "$R6\FreeOS\openxyos" 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 `if not exist "$INSTDIR\openxyos-runtime.zip" exit /b 2$\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 "$INSTDIR\openxyos-runtime.zip" -C "$INSTDIR\openxyos-runtime"$\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 "$INSTDIR\openxyos-runtime.zip" -C "$INSTDIR\openxyos"$\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 not exist "%LIVE%\node\node.exe" (`$\r$\n` + FileWrite $0 ` if exist "$INSTDIR\openxyos-runtime\node\node.exe" xcopy /E /I /Y "$INSTDIR\openxyos-runtime\*" "%LIVE%\" >nul`$\r$\n` + FileWrite $0 `)`$\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` 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 "Install tree: $INSTDIR\openxyos$\r$\n" - FileWrite $0 "Staged tree: $INSTDIR\openxyos-runtime$\r$\n" - FileWrite $0 "Work dir: $LOCALAPPDATA\FreeOS\openxyos$\r$\n" - FileWrite $0 "Also: %USERPROFILE%\.freeos\openxyos$\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.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" + FileClose $0 SetDetailsPrint listonly !macroend -# Live workdir is $INSTDIR\openxyos (node + openxyos\dist, or flattened dist). -# openxyos-runtime is the fully expanded zip (heals first-run if live is stub). -# Missing node.exe or dist\index.html aborts setup — never ship README-only. +# 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 "$INSTDIR\openxyos\node\node.exe" 0 openxyosTryStaged - IfFileExists "$INSTDIR\openxyos\openxyos\dist\index.html" openxyosLiveOk openxyosTryFlat + IfFileExists "$R6\FreeOS\openxyos\node\node.exe" 0 openxyosTryStaged + IfFileExists "$R6\FreeOS\openxyos\openxyos\dist\index.html" openxyosLiveOk openxyosTryFlat openxyosTryFlat: - IfFileExists "$INSTDIR\openxyos\dist\index.html" openxyosLiveOk openxyosTryStaged + 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 openxyosLayoutFail + 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 openxyosLayoutFail + 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\*" "$INSTDIR\openxyos\"' + nsExec::ExecToLog '"$SYSDIR\cmd.exe" /C xcopy /E /I /Y "$INSTDIR\openxyos-runtime\*" "$R6\FreeOS\openxyos\"' Pop $0 - IfFileExists "$INSTDIR\openxyos\node\node.exe" 0 openxyosLayoutFail - IfFileExists "$INSTDIR\openxyos\openxyos\dist\index.html" openxyosLiveOk openxyosTryFlatAfterCopy + IfFileExists "$R6\FreeOS\openxyos\node\node.exe" 0 openxyosTryInstHeal + IfFileExists "$R6\FreeOS\openxyos\openxyos\dist\index.html" openxyosLiveOk openxyosTryFlatAfterCopy openxyosTryFlatAfterCopy: - IfFileExists "$INSTDIR\openxyos\dist\index.html" openxyosLiveOk openxyosLayoutFail + 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 @@ -218,6 +260,57 @@ 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 + 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 `$$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 `$$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: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,http://localhost:8088,http://127.0.0.1:18900,http://localhost:18900'$\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 `exit 12$\r$\n` + FileClose $0 + nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "$PLUGINSDIR\probe-openxyos.ps1"' + Pop $0 + DetailPrint "$(OPENXYOS_EXTRACT_CODE)$0" + IntCmp $0 0 openxyosProbeOk openxyosProbeFail openxyosProbeFail + openxyosProbeFail: + DetailPrint "$(OPENXYOS_PROBE_FAIL)" + IfSilent openxyosProbeSilent openxyosProbeLoud + openxyosProbeSilent: + SetErrorLevel 68 + Abort + openxyosProbeLoud: + MessageBox MB_OK|MB_ICONSTOP "$(OPENXYOS_PROBE_FAIL)" + SetErrorLevel 68 + Abort + openxyosProbeOk: + DetailPrint "$(OPENXYOS_PROBE_OK)" +!macroend + !macro wails.writeUninstaller WriteUninstaller "$INSTDIR\uninstall.exe" diff --git a/desktop/src/main.go b/desktop/src/main.go index 82c7d811..01488064 100644 --- a/desktop/src/main.go +++ b/desktop/src/main.go @@ -189,6 +189,7 @@ func (a *App) boot() { return } root := portableDir() + openxyosReady := sidecarBundleReady(openxyosUserWorkDir()) if _, perr := provisionOpenXYOS(root, locale, a.setStatus); perr != nil { log.Printf("openXYOS provision: %v", perr) } @@ -232,7 +233,7 @@ func (a *App) boot() { } if sidecarReady(root) { wait := 20 * time.Second - if firstLaunch { + if firstLaunch && !openxyosReady { wait = 45 * time.Second } if err := waitSidecarLive(wait); err != nil { diff --git a/desktop/src/process_sidecar.go b/desktop/src/process_sidecar.go index 4fcdd6f8..9e6b9b59 100644 --- a/desktop/src/process_sidecar.go +++ b/desktop/src/process_sidecar.go @@ -297,7 +297,9 @@ 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) { - _ = writeOpenXYOSReadme(dest) + // Windows NSIS writes this tree during Setup. First launch must + // not copy or unpack when the install-time live root is complete. + log.Printf("openXYOS workdir already complete at %s", dest) return dest, nil } if status != nil { diff --git a/desktop/src/process_sidecar_test.go b/desktop/src/process_sidecar_test.go index 560c978a..047edcba 100644 --- a/desktop/src/process_sidecar_test.go +++ b/desktop/src/process_sidecar_test.go @@ -154,6 +154,31 @@ func TestSidecarBundleReadyRejectsReadmeOnly(t *testing.T) { } } +func TestProvisionOpenXYOSSkipsCopyWhenWorkdirReady(t *testing.T) { + root := t.TempDir() + t.Setenv("FREEOS_HOME", root) + work := filepath.Join(root, "workdir") + t.Setenv("FREEOS_OPENXYOS_HOME", work) + t.Setenv("LOCALAPPDATA", filepath.Join(root, "local")) + writeSidecarBundle(t, work) + portable := filepath.Join(root, "portable") + writeSidecarBundle(t, orgSidecarDir(portable)) + marker := filepath.Join(orgSidecarDir(portable), "copied-from-portable") + if err := os.WriteFile(marker, []byte("no"), 0o644); err != nil { + t.Fatal(err) + } + got, err := provisionOpenXYOS(portable, LocaleEN, nil) + if err != nil { + t.Fatal(err) + } + if got != work { + t.Fatalf("provision dest=%q want %q", got, work) + } + if _, err := os.Stat(filepath.Join(work, "copied-from-portable")); err == nil { + t.Fatal("install-time complete workdir must not be overwritten from portable") + } +} + func TestProvisionOpenXYOSHealsFromStagedRuntime(t *testing.T) { root := t.TempDir() t.Setenv("FREEOS_HOME", root) diff --git a/tests/unit/desktop/test_nsis_uninstall.py b/tests/unit/desktop/test_nsis_uninstall.py index a60449e2..72cfca62 100644 --- a/tests/unit/desktop/test_nsis_uninstall.py +++ b/tests/unit/desktop/test_nsis_uninstall.py @@ -80,6 +80,8 @@ def test_uninstall_removes_shortcuts_and_program_cache() -> None: assert 'Delete "$DESKTOP\\${INFO_PRODUCTNAME}.lnk"' in uninstall assert 'Delete "$SMSTARTUP\\${INFO_PRODUCTNAME}.lnk"' in uninstall assert r'RMDir /r "$AppData\${PRODUCT_EXECUTABLE}"' in uninstall + assert r'RMDir /r "$R6\${INFO_PRODUCTNAME}"' in uninstall + assert "!insertmacro wails.userLocalAppData" in uninstall assert "!insertmacro wails.deleteUninstaller" in uninstall @@ -143,7 +145,8 @@ def test_nsis_provisions_openxyos_runtime() -> None: assert 'File "/oname=openxyos-runtime.zip"' in nsh assert r"$INSTDIR\openxyos" in nsh assert r"$INSTDIR\openxyos-runtime" in nsh - assert r"$LOCALAPPDATA\FreeOS\openxyos" in nsh + assert r"$R6\FreeOS\openxyos" 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 @@ -174,15 +177,23 @@ def test_nsis_extracts_runtime_with_quoted_paths_and_aborts_if_incomplete() -> N assert r"$INSTDIR\openxyos-runtime.zip" in provision assert "nsExec::ExecToLog" in provision assert "Pop $0" in provision - assert r"$INSTDIR\openxyos\node\node.exe" in require - assert r"$INSTDIR\openxyos\openxyos\dist\index.html" in require + 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" in nsi assert "openxyos-runtime.zip missing" in nsh assert "!error" in nsh + assert "probe-openxyos.ps1" 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 + 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 def test_desktop_readme_documents_uninstall_keep_vs_remove() -> None: @@ -203,6 +214,7 @@ def test_desktop_readme_documents_uninstall_keep_vs_remove() -> None: assert "tar.exe" in text assert "README-only" in text assert "folder picker" in text + assert "install-time" in text.lower() or "during Setup" in text or "安装期" in text def test_organization_source_download_uses_native_folder_picker() -> None: From 869920d913ee5ee286a5e06036b9522afc679438 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 06:48:04 +0000 Subject: [PATCH 2/2] Fix NSIS FileWrite arity so makensis can compile Setup. A backtick immediately after '(' closed the FileWrite string and left $\r$\n as a third argument. Use cmd goto instead of parentheses, drop commas from the livez probe script, and assert every FileWrite has exactly two parameters. Co-authored-by: XYAI Labs --- .../src/build/windows/nsis/wails_tools.nsh | 10 ++--- tests/unit/desktop/test_nsis_uninstall.py | 41 +++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/desktop/src/build/windows/nsis/wails_tools.nsh b/desktop/src/build/windows/nsis/wails_tools.nsh index bca98fcf..9b21f741 100644 --- a/desktop/src/build/windows/nsis/wails_tools.nsh +++ b/desktop/src/build/windows/nsis/wails_tools.nsh @@ -182,9 +182,9 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" 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 not exist "%LIVE%\node\node.exe" (`$\r$\n` - FileWrite $0 ` if exist "$INSTDIR\openxyos-runtime\node\node.exe" xcopy /E /I /Y "$INSTDIR\openxyos-runtime\*" "%LIVE%\" >nul`$\r$\n` - FileWrite $0 `)`$\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` + 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` @@ -283,9 +283,9 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" 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,http://localhost:8088,http://127.0.0.1:18900,http://localhost:18900'$\r$\n` + FileWrite $0 `$$env:CORS_ORIGIN = 'http://127.0.0.1:8088'$\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 `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` diff --git a/tests/unit/desktop/test_nsis_uninstall.py b/tests/unit/desktop/test_nsis_uninstall.py index 72cfca62..74b28424 100644 --- a/tests/unit/desktop/test_nsis_uninstall.py +++ b/tests/unit/desktop/test_nsis_uninstall.py @@ -160,6 +160,47 @@ def test_nsis_provisions_openxyos_runtime() -> None: assert "openXYOS frontend missing" in package +def _nsis_filewrite_argc(line: str) -> int: + """Count FileWrite arguments the way makensis splits them (space/comma).""" + rest = line.strip() + prefix = "FileWrite" + if not rest.startswith(prefix): + return 0 + rest = rest[len(prefix) :].lstrip() + args: list[str] = [] + i = 0 + while i < len(rest): + ch = rest[i] + if ch in " \t,": + i += 1 + continue + if ch in "\"'`": + j = i + 1 + while j < len(rest) and rest[j] != ch: + j += 1 + args.append(rest[i : j + 1]) + i = j + 1 + continue + j = i + while j < len(rest) and rest[j] not in " \t,": + j += 1 + args.append(rest[i:j]) + i = j + return len(args) + + +def test_nsis_filewrite_is_exactly_two_args() -> None: + """A backtick next to '(' closes the string; $\\r$\\n becomes a third arg.""" + nsh = NSH.read_text(encoding="utf-8") + for lineno, raw in enumerate(nsh.splitlines(), start=1): + stripped = raw.strip() + if not stripped.startswith("FileWrite"): + continue + argc = _nsis_filewrite_argc(stripped) + assert argc == 2, f"wails_tools.nsh:{lineno}: FileWrite argc={argc}: {stripped}" + 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.""" nsi = NSI.read_text(encoding="utf-8-sig")