From 142ae340686b2cbd153761b535e1bd3e4520b01c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 23:55:07 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(desktop):=20=E5=AE=89=E8=A3=85=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E8=87=AA=E5=8A=A8=E5=90=AF=E5=8A=A8=E5=B9=B6=E5=85=B3?= =?UTF-8?q?=E9=97=AD=EF=BC=9B=E9=94=81=E5=AE=9A=20portable=20=E5=8E=9F?= =?UTF-8?q?=E5=9C=B0=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows NSIS 不再显示结束页:文件复制完成后自动启动 FreeOS 并关闭安装窗口(/S 静默安装不拉起)。升级时若 portable 目录无法改名为 portable.previous,先结束残留进程再原地覆盖新运行时。 Co-authored-by: XYAI Labs --- CHANGELOG.md | 2 + desktop/README.md | 16 ++- desktop/src/build/windows/nsis/project.nsi | 19 +-- desktop/src/download.go | 153 ++++++++++++++++++++- desktop/src/download_test.go | 78 +++++++++++ desktop/src/process_unix.go | 2 + desktop/src/process_windows.go | 29 ++++ tests/unit/desktop/test_nsis_uninstall.py | 25 ++-- 8 files changed, 297 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 468e0fb8..654fce62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,12 @@ ### 变更 +- Windows NSIS 安装完成后不再停留在结束页:文件复制一结束即自动启动 FreeOS 并关闭安装窗口,用户不必再点「下一步 / 完成 / 关闭」。静默安装(`/S`)仍不拉起界面。 / After a Windows NSIS install finishes, FreeOS launches automatically and Setup closes — no Finish click. Silent `/S` installs stay headless. - 桌面首次启动不再要求注册/登录。首屏是可选模型配置(云密钥或本机 Ollama,可跳过);跳过或保存后进入默认智能体对话,而不是停在工作台列表。已有提供商或会话的用户不会被再次拦住。账号仍可稍后在头像菜单里领取,供保存/导出/组织房间使用。首次运行与安全条目一致:不预填云密钥,本机 Ollama 优先。 ### 修复 +- Windows 升级刷新 `~/.freeos/portable` 时,若目录节点被占用(无法改名为 `portable.previous`,报 “being used by another process”),先尝试结束残留的 portable / `launch.py` 进程,再把新运行时**原地覆盖**进现有文件夹,避免留下空的锁定 `portable` 桩,也不要求重启。 / If renaming `portable` → `portable.previous` fails because the directory is in use, stop leftover host processes and overlay the new runtime in place. - 桌面 `POST /api/auth/local-session` 对已有 `~/.freeos`(多用户 / 组织映射行)或 WebView 非 `127.0.0.1` Host 返回 403,前端重试后掉进注册登录。本机会话在 loopback / `*.localhost` / Origin 为本机时签发 JWT 并选用已有工作室账号;SPA 在 `/` 跳到 `/projects` 丢掉 `?desktop=1` 之前记住桌面壳。403 修复后的路径是:可选模型配置(云 Key / 本机,可跳过)→ 第一个智能体 `/chat/main`,不经过登录墙,也不停在工作台列表。`/setup` 在 guest 已创建后不再打回登录页。 - Windows 安装 / 覆盖安装 / 同版本重装会刷新 `~/.freeos/portable`:Setup 先结束仍在运行的 FreeOS,写入 `$INSTDIR\FREEOS_INSTALL_STAMP`,并清除已解压树里的 `FREEOS_STAMP`。下次启动按安装戳 + 包内戳重新解压 `packages/` 与内嵌 Dashboard,#88 及后续宿主/界面修复不必再手工热补。用户数据库与设置仍留在 `~/.freeos`。 - 桌面回访(已有 JWT / `has_providers=true`)不再停在 Octop `/projects` 工作台,也不把宿主原生组织页当首屏。窗口打开 `/chat/main?desktop=1`;SPA `/` 与裸 `/chat` 在桌面壳里进第一个智能体。组织仍是侧栏里的另一间房间,首次会话不自动走进去。 diff --git a/desktop/README.md b/desktop/README.md index 2e16a22d..fa1fd337 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -42,11 +42,11 @@ First launch opens the model wizard with **Ollama first**. Skip is fine; **Model ## Windows install finish -The NSIS finish page offers **运行 FreeOS** / **Run FreeOS**, checked by -default. Leave it checked to start FreeOS from `$INSTDIR` when Setup -closes (working directory is the install folder). The launch uses the -unelevated explorer token so the first run does not stamp `%USERPROFILE%\.freeos` -as High integrity. Uncheck to skip. Chinese installer strings are compiled +After the file-copy page finishes, Setup **starts FreeOS** from `$INSTDIR` +and **closes itself**. There is no Finish / Next / Close click. The launch +uses the unelevated explorer token so the first run does not stamp +`%USERPROFILE%\.freeos` as High integrity. Silent (`/S`) installs stay +headless and do not auto-launch. Chinese installer strings are compiled with `makensis -INPUTCHARSET UTF8` from a UTF-8 BOM `project.nsi`. Upgrade / reinstall (same version included) refreshes the extracted runtime: @@ -63,7 +63,11 @@ Upgrade / reinstall (same version included) refreshes the extracted runtime: Either mismatch re-extracts `packages/` and the embedded dashboard from the new installer into `~/.freeos/portable`, then copies the install stamp into that tree. Later opens skip the extract until the next - Setup run. + Setup run. If renaming the live `portable` directory to + `portable.previous` fails because Windows still has the folder open + (common when a leftover Python host holds the directory node), the + shell stops leftover `launch.py` / portable processes and **overwrites + files in place** instead of requiring a reboot. A healthy default package is **FreeOS.exe + the Python host** — no `openxyos-runtime.zip` (~500MB Node payload). Pass diff --git a/desktop/src/build/windows/nsis/project.nsi b/desktop/src/build/windows/nsis/project.nsi index b6e191dd..a5c201ef 100644 --- a/desktop/src/build/windows/nsis/project.nsi +++ b/desktop/src/build/windows/nsis/project.nsi @@ -31,18 +31,13 @@ ManifestDPIAware true !define MUI_ICON "..\icon.ico" !define MUI_UNICON "..\icon.ico" -!define MUI_FINISHPAGE_NOAUTOCLOSE !define MUI_ABORTWARNING -# Checkbox is shown only when MUI_FINISHPAGE_RUN is set. Leave the -# "not checked" finish-page flag undefined so the box stays on. -!define MUI_FINISHPAGE_RUN "$INSTDIR\${PRODUCT_EXECUTABLE}" -!define MUI_FINISHPAGE_RUN_TEXT "$(FINISH_RUN)" -!define MUI_FINISHPAGE_RUN_FUNCTION LaunchFreeOS +# No finish / Next / Close click after files copy. INSTFILES auto-closes, +# then .onInstSuccess launches FreeOS (skipped for silent /S installs). !insertmacro MUI_PAGE_WELCOME !insertmacro MUI_PAGE_DIRECTORY !insertmacro MUI_PAGE_INSTFILES -!insertmacro MUI_PAGE_FINISH !insertmacro MUI_UNPAGE_INSTFILES @@ -50,8 +45,6 @@ ManifestDPIAware true !insertmacro MUI_LANGUAGE "English" !insertmacro MUI_RESERVEFILE_LANGDLL -LangString FINISH_RUN ${LANG_SIMPCHINESE} "运行 FreeOS" -LangString FINISH_RUN ${LANG_ENGLISH} "Run FreeOS" LangString OPENXYOS_WORKDIR ${LANG_SIMPCHINESE} "正在复制过渡用组织运行时(受管 Node)。组织能力在 FreeOS 宿主内运行;此路径不是终态。" LangString OPENXYOS_WORKDIR ${LANG_ENGLISH} "Copying the transitional organization runtime (managed Node). Organization runs in-host; this is not the destination. Setup continues." LangString OPENXYOS_COPY_ZIP ${LANG_SIMPCHINESE} "正在复制过渡用 openXYOS 运行包" @@ -104,6 +97,7 @@ Name "${INFO_PRODUCTNAME}" !define INSTALLER_OUTFILE "..\..\..\bin\${INFO_PROJECTNAME}-desktop-windows-${ARCH}-${INFO_PRODUCTVERSION}.exe" !endif OutFile "${INSTALLER_OUTFILE}" +AutoCloseWindow true !if "${WAILS_INSTALL_SCOPE}" == "user" InstallDir "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}" !else @@ -144,8 +138,15 @@ Section !insertmacro wails.associateCustomProtocols !insertmacro wails.writeUninstaller + SetAutoClose true SectionEnd +Function .onInstSuccess + IfSilent skipLaunch + Call LaunchFreeOS + skipLaunch: +FunctionEnd + Section "uninstall" !insertmacro wails.setShellContext ; Processes were already confirmed + stopped in un.onInit when present. diff --git a/desktop/src/download.go b/desktop/src/download.go index b8fc1c02..83a68869 100644 --- a/desktop/src/download.go +++ b/desktop/src/download.go @@ -16,6 +16,7 @@ import ( "sort" "strconv" "strings" + "syscall" "time" ) @@ -95,8 +96,19 @@ func replacePortable(root string) error { hadCurrent := false if _, err := os.Stat(root); err == nil { if err := renamePortable(root, previous); err != nil { - _ = os.RemoveAll(next) - return err + // Directory node is often locked on Windows while files inside + // can still be overwritten. Stop leftover host processes, retry + // the rename, then overlay in place so Setup does not need a reboot. + stopPortableHoldersFn(root) + if retry := renamePortable(root, previous); retry != nil { + log.Printf("portable: rename %s failed (%v); refreshing in place", root, retry) + if inplaceErr := overlayPortable(next, root); inplaceErr != nil { + _ = os.RemoveAll(next) + return inplaceErr + } + _ = os.RemoveAll(next) + return nil + } } hadCurrent = true } @@ -104,13 +116,33 @@ func replacePortable(root string) error { if hadCurrent { _ = renamePortable(previous, root) } + if isBusyPathError(err) { + log.Printf("portable: swap %s into place failed (%v); overlaying in place", root, err) + if inplaceErr := overlayPortable(next, root); inplaceErr != nil { + _ = os.RemoveAll(next) + return inplaceErr + } + _ = os.RemoveAll(next) + _ = os.RemoveAll(previous) + return nil + } + _ = os.RemoveAll(next) return err } _ = os.RemoveAll(previous) return nil } +var ( + renamePortableImpl = renamePortableWithRetry + stopPortableHoldersFn = stopPortableHolders +) + func renamePortable(source string, target string) error { + return renamePortableImpl(source, target) +} + +func renamePortableWithRetry(source string, target string) error { var last error for attempt := 0; attempt < 25; attempt++ { if err := os.Rename(source, target); err == nil { @@ -123,6 +155,123 @@ func renamePortable(source string, target string) error { return last } +func isBusyPathError(err error) bool { + if err == nil { + return false + } + var errno syscall.Errno + if errors.As(err, &errno) { + switch errno { + case 5, 32, 33: // ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION, ERROR_LOCK_VIOLATION + return true + } + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "being used by another process") || + strings.Contains(msg, "used by another process") +} + +func overlayPortable(src, dest string) error { + src = filepath.Clean(src) + dest = filepath.Clean(dest) + if err := os.MkdirAll(dest, 0o755); err != nil { + return err + } + err := filepath.WalkDir(src, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, relErr := filepath.Rel(src, path) + if relErr != nil { + return relErr + } + if rel == "." { + return nil + } + target := filepath.Join(dest, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + info, infoErr := d.Info() + if infoErr != nil { + return infoErr + } + if info.Mode()&os.ModeSymlink != 0 { + link, linkErr := os.Readlink(path) + if linkErr != nil { + return linkErr + } + _ = os.Remove(target) + if mkErr := os.MkdirAll(filepath.Dir(target), 0o755); mkErr != nil { + return mkErr + } + return os.Symlink(link, target) + } + return copyFileOver(path, target, info.Mode()) + }) + if err != nil { + return err + } + pruneOverlayExtras(src, dest) + if !launchReady(dest) { + return fmt.Errorf("portable extract missing launch.py or python under %s", dest) + } + return nil +} + +func copyFileOver(src, dest string, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) + if err != nil { + return err + } + _, copyErr := io.Copy(out, in) + closeErr := out.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + if chmodErr := os.Chmod(dest, mode); chmodErr != nil && runtime.GOOS != "windows" { + return chmodErr + } + return nil +} + +func pruneOverlayExtras(src, dest string) { + var extras []string + _ = filepath.WalkDir(dest, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return nil + } + rel, relErr := filepath.Rel(dest, path) + if relErr != nil || rel == "." { + return nil + } + if _, err := os.Lstat(filepath.Join(src, rel)); err == nil { + return nil + } + extras = append(extras, path) + if d.IsDir() { + return filepath.SkipDir + } + return nil + }) + for _, path := range extras { + if err := os.RemoveAll(path); err != nil { + log.Printf("portable: leave locked leftover %s: %v", path, err) + } + } +} + const portableStampName = "FREEOS_STAMP" const installStampName = "FREEOS_INSTALL_STAMP" diff --git a/desktop/src/download_test.go b/desktop/src/download_test.go index 276edd7e..cbc55b64 100644 --- a/desktop/src/download_test.go +++ b/desktop/src/download_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "runtime" "strings" + "syscall" "testing" "time" ) @@ -422,6 +423,83 @@ func TestEnsurePortableReplacesSameVersionWhenStampChanges(t *testing.T) { } } +func TestIsBusyPathError(t *testing.T) { + if isBusyPathError(nil) { + t.Fatal("nil must not look busy") + } + if !isBusyPathError(errors.New("The process cannot access the file because it is being used by another process")) { + t.Fatal("Windows sharing-violation text should look busy") + } + if !isBusyPathError(syscall.Errno(32)) { + t.Fatal("ERROR_SHARING_VIOLATION should look busy") + } + if isBusyPathError(errors.New("no such file or directory")) { + t.Fatal("missing path is not a busy lock") + } +} + +func TestReplacePortableFallsBackToInPlaceWhenRenameBusy(t *testing.T) { + home := t.TempDir() + t.Setenv("OCTOP_HOME", home) + root := portableDir() + + oldZip := filepath.Join(t.TempDir(), "old.zip") + writeTestGreenZip(t, oldZip, "0.0.1") + if err := unzipGreen(oldZip, root); err != nil { + t.Fatal(err) + } + stale := filepath.Join(root, "stale.txt") + if err := os.WriteFile(stale, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + + newZip := filepath.Join(t.TempDir(), "new.zip") + writeTestGreenZip(t, newZip, "0.0.2") + t.Setenv("OCTOP_DESKTOP_PORTABLE_ZIP", newZip) + + stopped := 0 + prevStop := stopPortableHoldersFn + stopPortableHoldersFn = func(path string) { + stopped++ + if path != root { + t.Fatalf("stopPortableHolders path = %q, want %q", path, root) + } + } + prevRename := renamePortableImpl + renamePortableImpl = func(source, target string) error { + if source == root && strings.HasSuffix(target, ".previous") { + return errors.New("The process cannot access the file because it is being used by another process") + } + return os.Rename(source, target) + } + t.Cleanup(func() { + stopPortableHoldersFn = prevStop + renamePortableImpl = prevRename + }) + + if err := ensurePortable(LocaleZH, func(string) {}); err != nil { + t.Fatal(err) + } + if stopped == 0 { + t.Fatal("expected leftover portable holders to be stopped before in-place refresh") + } + if got := portableVersion(root); got != "0.0.2" { + t.Fatalf("portable version = %q, want 0.0.2 after in-place refresh", got) + } + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Fatalf("stale file should be pruned during in-place refresh: %v", err) + } + if _, err := os.Stat(root + ".new"); !os.IsNotExist(err) { + t.Fatalf("temporary .new tree should be removed: %v", err) + } + if _, err := os.Stat(root + ".previous"); !os.IsNotExist(err) { + t.Fatalf("failed rename should not leave portable.previous: %v", err) + } + if !launchReady(root) { + t.Fatal("in-place refresh should leave a launch-ready runtime") + } +} + func TestEnsurePortableKeepsCurrentRuntimeWhenReplacementIsInvalid(t *testing.T) { home := t.TempDir() t.Setenv("OCTOP_HOME", home) diff --git a/desktop/src/process_unix.go b/desktop/src/process_unix.go index 24067613..36986044 100644 --- a/desktop/src/process_unix.go +++ b/desktop/src/process_unix.go @@ -30,3 +30,5 @@ func killPid(pid int) { } func killWindowsImageAt(string) {} + +func stopPortableHolders(string) {} diff --git a/desktop/src/process_windows.go b/desktop/src/process_windows.go index 756c2be4..bc3c7955 100644 --- a/desktop/src/process_windows.go +++ b/desktop/src/process_windows.go @@ -43,6 +43,35 @@ func killPid(pid int) { _ = kill.Run() } +func stopPortableHolders(root string) { + root = filepath.Clean(strings.TrimSpace(root)) + if root == "" { + return + } + ps := filepath.Join(os.Getenv("SystemRoot"), `System32`, `WindowsPowerShell`, `v1.0`, `powershell.exe`) + if os.Getenv("SystemRoot") == "" { + ps = `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe` + } + script := ` +param($Root, $SelfPid) +$ErrorActionPreference = 'SilentlyContinue' +$want = $Root +try { $want = [IO.Path]::GetFullPath($Root) } catch {} +Get-CimInstance Win32_Process | Where-Object { + $_.ProcessId -ne [int]$SelfPid -and ( + ($_.CommandLine -like ('*' + $want + '*launch.py*')) -or + ($_.ExecutablePath -like ($want + '*')) + ) +} | ForEach-Object { + try { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } catch {} +} +Start-Sleep -Milliseconds 400 +` + cmd := exec.Command(ps, "-NoProfile", "-Command", script, "-Root", root, "-SelfPid", strconv.Itoa(os.Getpid())) + hideConsole(cmd) + _ = cmd.Run() +} + func killWindowsImageAt(exe string) { exe = strings.TrimSpace(exe) if exe == "" { diff --git a/tests/unit/desktop/test_nsis_uninstall.py b/tests/unit/desktop/test_nsis_uninstall.py index b92e01c2..b5ac6f50 100644 --- a/tests/unit/desktop/test_nsis_uninstall.py +++ b/tests/unit/desktop/test_nsis_uninstall.py @@ -140,17 +140,19 @@ def test_uninstall_removes_shortcuts_and_program_cache() -> None: assert "!insertmacro wails.deleteUninstaller" in uninstall -def test_finish_page_run_defaults_checked() -> None: +def test_install_autolaunches_and_closes_without_finish_page() -> None: nsi = NSI.read_text(encoding="utf-8-sig") - assert '!define MUI_FINISHPAGE_RUN "$INSTDIR\\${PRODUCT_EXECUTABLE}"' in nsi - assert "!define MUI_FINISHPAGE_RUN_FUNCTION LaunchFreeOS" in nsi - assert "!define MUI_FINISHPAGE_RUN_TEXT" in nsi - assert "LangString FINISH_RUN ${LANG_SIMPCHINESE}" in nsi - assert "运行 FreeOS" in nsi - assert "!define MUI_FINISHPAGE_RUN_NOTCHECKED" not in nsi + assert "!insertmacro MUI_PAGE_FINISH" not in nsi + assert "MUI_FINISHPAGE_RUN" not in nsi + assert "MUI_FINISHPAGE_NOAUTOCLOSE" not in nsi + assert "AutoCloseWindow true" in nsi + assert "SetAutoClose true" in nsi + success = nsi[nsi.index("Function .onInstSuccess") : nsi.index("Section \"uninstall\"")] + assert "IfSilent" in success + assert "Call LaunchFreeOS" in success launch = nsi[nsi.index("Function LaunchFreeOS") :] assert 'SetOutPath "$INSTDIR"' in launch - # Finish-page launch must drop the installer admin token. + # Auto-launch must drop the installer admin token. assert "CoCreateInstance" in launch assert "IShellDispatch2" in launch or "A4C6892C-3BA9-11d2-9DEA-00C04FB16162" in launch assert r'"$WINDIR\explorer.exe"' in launch @@ -172,8 +174,8 @@ def test_nsis_chinese_source_is_utf8_with_bom() -> None: raw = NSI.read_bytes() assert raw.startswith(b"\xef\xbb\xbf"), "makensis treats BOM-less files as ACP (CP1252 on CI)" text = raw.decode("utf-8-sig") - assert "运行 FreeOS" in text assert "检测到 FreeOS 仍在运行" in text + assert "正在复制过渡用组织运行时" in text mojibake = "运行".encode().decode("cp1252", errors="replace") assert mojibake not in text task = (REPO / "desktop" / "src" / "build" / "windows" / "Taskfile.yml").read_text( @@ -316,7 +318,10 @@ def test_desktop_readme_documents_uninstall_keep_vs_remove() -> None: assert "Cancel" in text assert "Confirm" in text assert "## Windows install finish" in text - assert "运行 FreeOS" in text + assert "starts FreeOS" in text + assert "closes itself" in text + assert "portable.previous" in text + assert "in place" in text assert "FREEOS_INSTALL_STAMP" in text assert "FREEOS_STAMP" in text assert "%USERPROFILE%\\.freeos\\portable" in text From 0b0134e365a1e546b105fc91d0da6ddad13698a1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 23:58:00 +0000 Subject: [PATCH 2/3] style(desktop): ruff format NSIS installer contract test CI lint failed because ruff would reformat a quote in test_nsis_uninstall.py. No product behavior change. Co-authored-by: XYAI Labs --- tests/unit/desktop/test_nsis_uninstall.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/desktop/test_nsis_uninstall.py b/tests/unit/desktop/test_nsis_uninstall.py index b5ac6f50..29193861 100644 --- a/tests/unit/desktop/test_nsis_uninstall.py +++ b/tests/unit/desktop/test_nsis_uninstall.py @@ -147,7 +147,7 @@ def test_install_autolaunches_and_closes_without_finish_page() -> None: assert "MUI_FINISHPAGE_NOAUTOCLOSE" not in nsi assert "AutoCloseWindow true" in nsi assert "SetAutoClose true" in nsi - success = nsi[nsi.index("Function .onInstSuccess") : nsi.index("Section \"uninstall\"")] + success = nsi[nsi.index("Function .onInstSuccess") : nsi.index('Section "uninstall"')] assert "IfSilent" in success assert "Call LaunchFreeOS" in success launch = nsi[nsi.index("Function LaunchFreeOS") :] From 9f93bbb494a2f7244fe872eae0b2b7f4ad65d498 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 23:59:36 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(desktop):=20=E6=81=A2=E5=A4=8D=E7=BB=93?= =?UTF-8?q?=E6=9D=9F=E9=A1=B5=E8=87=AA=E9=80=89=E5=90=AF=E5=8A=A8=E6=88=96?= =?UTF-8?q?=E5=85=B3=E9=97=AD=EF=BC=8C=E4=B8=8D=E5=86=8D=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=90=AF=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 文件复制后直接进入结束页(不再多点下一步)。用户勾选「运行 FreeOS」或取消后关闭安装程序。去掉 AutoCloseWindow / SetAutoClose / .onInstSuccess 自动启动。锁定 portable 原地刷新保持不变。 Co-authored-by: XYAI Labs --- CHANGELOG.md | 2 +- desktop/README.md | 16 +++++++----- desktop/src/build/windows/nsis/project.nsi | 22 ++++++++-------- tests/unit/desktop/test_nsis_uninstall.py | 30 ++++++++++++---------- 4 files changed, 40 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 654fce62..fa12307c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ ### 变更 -- Windows NSIS 安装完成后不再停留在结束页:文件复制一结束即自动启动 FreeOS 并关闭安装窗口,用户不必再点「下一步 / 完成 / 关闭」。静默安装(`/S`)仍不拉起界面。 / After a Windows NSIS install finishes, FreeOS launches automatically and Setup closes — no Finish click. Silent `/S` installs stay headless. +- Windows NSIS 文件复制结束后不再多点一次「下一步」才到结束页;结束页仍让用户勾选「运行 FreeOS」或直接关闭,不会自动启动、也不会自动关窗。静默安装(`/S`)仍不拉起界面。 / After Windows NSIS file copy, Setup advances to the finish page without an extra Next. The user still chooses Run FreeOS or close. No auto-launch / auto-close. Silent `/S` stays headless. - 桌面首次启动不再要求注册/登录。首屏是可选模型配置(云密钥或本机 Ollama,可跳过);跳过或保存后进入默认智能体对话,而不是停在工作台列表。已有提供商或会话的用户不会被再次拦住。账号仍可稍后在头像菜单里领取,供保存/导出/组织房间使用。首次运行与安全条目一致:不预填云密钥,本机 Ollama 优先。 ### 修复 diff --git a/desktop/README.md b/desktop/README.md index fa1fd337..9e5726aa 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -42,12 +42,16 @@ First launch opens the model wizard with **Ollama first**. Skip is fine; **Model ## Windows install finish -After the file-copy page finishes, Setup **starts FreeOS** from `$INSTDIR` -and **closes itself**. There is no Finish / Next / Close click. The launch -uses the unelevated explorer token so the first run does not stamp -`%USERPROFILE%\.freeos` as High integrity. Silent (`/S`) installs stay -headless and do not auto-launch. Chinese installer strings are compiled -with `makensis -INPUTCHARSET UTF8` from a UTF-8 BOM `project.nsi`. +Pages are Welcome → directory → file copy → **finish**. After files copy, +Setup goes to the finish page without an extra Next on the progress list. +The finish page offers **运行 FreeOS** / **Run FreeOS**, checked by +default: leave it checked and click Finish to start FreeOS from `$INSTDIR`, +or uncheck it and click Finish to close Setup without launching. Setup +does **not** auto-launch or auto-close. The launch uses the unelevated +explorer token so the first run does not stamp `%USERPROFILE%\.freeos` +as High integrity. Silent (`/S`) installs stay headless and do not launch. +Chinese installer strings are compiled with `makensis -INPUTCHARSET UTF8` +from a UTF-8 BOM `project.nsi`. Upgrade / reinstall (same version included) refreshes the extracted runtime: diff --git a/desktop/src/build/windows/nsis/project.nsi b/desktop/src/build/windows/nsis/project.nsi index a5c201ef..c9385c06 100644 --- a/desktop/src/build/windows/nsis/project.nsi +++ b/desktop/src/build/windows/nsis/project.nsi @@ -32,12 +32,20 @@ ManifestDPIAware true !define MUI_ICON "..\icon.ico" !define MUI_UNICON "..\icon.ico" !define MUI_ABORTWARNING -# No finish / Next / Close click after files copy. INSTFILES auto-closes, -# then .onInstSuccess launches FreeOS (skipped for silent /S installs). +# Finish page keeps the launch-vs-close choice. After files copy, +# INSTFILES advances to Finish without an extra Next. Do not dismiss +# Setup or start FreeOS without the checkbox — the user picks +# 运行 FreeOS or just closes. Checkbox is shown only when +# MUI_FINISHPAGE_RUN is set. Leave the "not checked" finish-page +# flag undefined so the box stays on. +!define MUI_FINISHPAGE_RUN "$INSTDIR\${PRODUCT_EXECUTABLE}" +!define MUI_FINISHPAGE_RUN_TEXT "$(FINISH_RUN)" +!define MUI_FINISHPAGE_RUN_FUNCTION LaunchFreeOS !insertmacro MUI_PAGE_WELCOME !insertmacro MUI_PAGE_DIRECTORY !insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH !insertmacro MUI_UNPAGE_INSTFILES @@ -45,6 +53,8 @@ ManifestDPIAware true !insertmacro MUI_LANGUAGE "English" !insertmacro MUI_RESERVEFILE_LANGDLL +LangString FINISH_RUN ${LANG_SIMPCHINESE} "运行 FreeOS" +LangString FINISH_RUN ${LANG_ENGLISH} "Run FreeOS" LangString OPENXYOS_WORKDIR ${LANG_SIMPCHINESE} "正在复制过渡用组织运行时(受管 Node)。组织能力在 FreeOS 宿主内运行;此路径不是终态。" LangString OPENXYOS_WORKDIR ${LANG_ENGLISH} "Copying the transitional organization runtime (managed Node). Organization runs in-host; this is not the destination. Setup continues." LangString OPENXYOS_COPY_ZIP ${LANG_SIMPCHINESE} "正在复制过渡用 openXYOS 运行包" @@ -97,7 +107,6 @@ Name "${INFO_PRODUCTNAME}" !define INSTALLER_OUTFILE "..\..\..\bin\${INFO_PROJECTNAME}-desktop-windows-${ARCH}-${INFO_PRODUCTVERSION}.exe" !endif OutFile "${INSTALLER_OUTFILE}" -AutoCloseWindow true !if "${WAILS_INSTALL_SCOPE}" == "user" InstallDir "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}" !else @@ -138,15 +147,8 @@ Section !insertmacro wails.associateCustomProtocols !insertmacro wails.writeUninstaller - SetAutoClose true SectionEnd -Function .onInstSuccess - IfSilent skipLaunch - Call LaunchFreeOS - skipLaunch: -FunctionEnd - Section "uninstall" !insertmacro wails.setShellContext ; Processes were already confirmed + stopped in un.onInit when present. diff --git a/tests/unit/desktop/test_nsis_uninstall.py b/tests/unit/desktop/test_nsis_uninstall.py index 29193861..dfe1ece8 100644 --- a/tests/unit/desktop/test_nsis_uninstall.py +++ b/tests/unit/desktop/test_nsis_uninstall.py @@ -140,19 +140,23 @@ def test_uninstall_removes_shortcuts_and_program_cache() -> None: assert "!insertmacro wails.deleteUninstaller" in uninstall -def test_install_autolaunches_and_closes_without_finish_page() -> None: +def test_finish_page_run_defaults_checked() -> None: nsi = NSI.read_text(encoding="utf-8-sig") - assert "!insertmacro MUI_PAGE_FINISH" not in nsi - assert "MUI_FINISHPAGE_RUN" not in nsi - assert "MUI_FINISHPAGE_NOAUTOCLOSE" not in nsi - assert "AutoCloseWindow true" in nsi - assert "SetAutoClose true" in nsi - success = nsi[nsi.index("Function .onInstSuccess") : nsi.index('Section "uninstall"')] - assert "IfSilent" in success - assert "Call LaunchFreeOS" in success + assert "!insertmacro MUI_PAGE_FINISH" in nsi + assert '!define MUI_FINISHPAGE_RUN "$INSTDIR\\${PRODUCT_EXECUTABLE}"' in nsi + assert "!define MUI_FINISHPAGE_RUN_FUNCTION LaunchFreeOS" in nsi + assert "!define MUI_FINISHPAGE_RUN_TEXT" in nsi + assert "LangString FINISH_RUN ${LANG_SIMPCHINESE}" in nsi + assert "运行 FreeOS" in nsi + assert "!define MUI_FINISHPAGE_RUN_NOTCHECKED" not in nsi + # Progress page must advance to Finish without an extra Next. + assert "!define MUI_FINISHPAGE_NOAUTOCLOSE" not in nsi + assert "AutoCloseWindow true" not in nsi + assert "SetAutoClose true" not in nsi + assert "Function .onInstSuccess" not in nsi launch = nsi[nsi.index("Function LaunchFreeOS") :] assert 'SetOutPath "$INSTDIR"' in launch - # Auto-launch must drop the installer admin token. + # Finish-page launch must drop the installer admin token. assert "CoCreateInstance" in launch assert "IShellDispatch2" in launch or "A4C6892C-3BA9-11d2-9DEA-00C04FB16162" in launch assert r'"$WINDIR\explorer.exe"' in launch @@ -174,8 +178,8 @@ def test_nsis_chinese_source_is_utf8_with_bom() -> None: raw = NSI.read_bytes() assert raw.startswith(b"\xef\xbb\xbf"), "makensis treats BOM-less files as ACP (CP1252 on CI)" text = raw.decode("utf-8-sig") + assert "运行 FreeOS" in text assert "检测到 FreeOS 仍在运行" in text - assert "正在复制过渡用组织运行时" in text mojibake = "运行".encode().decode("cp1252", errors="replace") assert mojibake not in text task = (REPO / "desktop" / "src" / "build" / "windows" / "Taskfile.yml").read_text( @@ -318,8 +322,8 @@ def test_desktop_readme_documents_uninstall_keep_vs_remove() -> None: assert "Cancel" in text assert "Confirm" in text assert "## Windows install finish" in text - assert "starts FreeOS" in text - assert "closes itself" in text + assert "运行 FreeOS" in text + assert "does **not** auto-launch" in text or "does not auto-launch" in text assert "portable.previous" in text assert "in place" in text assert "FREEOS_INSTALL_STAMP" in text