diff --git a/CHANGELOG.md b/CHANGELOG.md index 468e0fb8..fa12307c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,12 @@ ### 变更 +- 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 优先。 ### 修复 +- 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..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 -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 -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: @@ -63,7 +67,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..c9385c06 100644 --- a/desktop/src/build/windows/nsis/project.nsi +++ b/desktop/src/build/windows/nsis/project.nsi @@ -31,10 +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. +# 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 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..dfe1ece8 100644 --- a/tests/unit/desktop/test_nsis_uninstall.py +++ b/tests/unit/desktop/test_nsis_uninstall.py @@ -142,12 +142,18 @@ def test_uninstall_removes_shortcuts_and_program_cache() -> None: def test_finish_page_run_defaults_checked() -> None: nsi = NSI.read_text(encoding="utf-8-sig") + 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 # Finish-page launch must drop the installer admin token. @@ -317,6 +323,9 @@ def test_desktop_readme_documents_uninstall_keep_vs_remove() -> None: assert "Confirm" in text assert "## Windows install finish" 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 assert "FREEOS_STAMP" in text assert "%USERPROFILE%\\.freeos\\portable" in text