diff --git a/CHANGELOG.md b/CHANGELOG.md index e2496678..3cb7e782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### 修复 - 桌面 `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`。 ### 文档 diff --git a/desktop/README.md b/desktop/README.md index 78a36f21..a4cec135 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -49,6 +49,22 @@ 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`. +Upgrade / reinstall (same version included) refreshes the extracted runtime: + +1. Setup stops a running FreeOS (asks first; silent installs stop without + asking) so `FreeOS.exe` can be overwritten and the next launch is not + handed off to the old process. +2. Setup writes `$INSTDIR\FREEOS_INSTALL_STAMP` (product version + tick + count, unique per Setup run) and deletes + `%USERPROFILE%\.freeos\portable\FREEOS_STAMP` (also `FREEOS_HOME` / + `OCTOP_HOME` / leftover `~/.octop/portable` when those env vars are + set). User data (`octop.db`, settings, chats) is not deleted. +3. The next launch compares the install stamp and the zip `FREEOS_STAMP`. + 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. + A healthy default package is **FreeOS.exe + the Python host** — no `openxyos-runtime.zip` (~500MB Node payload). Pass `SHIP_OPENXYOS_RUNTIME=1` to `wails3 task package` (and when assembling @@ -177,16 +193,21 @@ cd desktop/src OCTOP_DESKTOP_URL=http://127.0.0.1:8088 wails3 dev ``` -Without `OCTOP_DESKTOP_URL`, first launch uses `~/.freeos/portable/` if valid, -otherwise extracts the matching zip shipped with the desktop package (embedded -in the Windows and Linux binaries, under `Contents/Resources` on macOS). The -Wails shell never downloads the host over the network. For local runtime -debugging, set +Without `OCTOP_DESKTOP_URL`, first launch uses `~/.freeos/portable/` if valid +**and** it already matches this install (same `FREEOS_STAMP` / install stamp); +otherwise it extracts the matching zip shipped with the desktop package +(embedded in the Windows and Linux binaries, under `Contents/Resources` on +macOS). The Wails shell never downloads the host over the network. For local +runtime debugging, set `OCTOP_DESKTOP_PORTABLE_ZIP=/absolute/path/FreeOS-portable--.zip`. -On later launches, a newer bundled portable version replaces the extracted -runtime after creating a consistent SQLite backup under `{home}/backups/`. -The upgraded host then applies the normal database migrations during startup. -Newer extracted runtimes are never downgraded; PostgreSQL remains externally +On later launches, a newer bundled portable version — or the same version +with a new `FREEOS_STAMP` / Windows `FREEOS_INSTALL_STAMP` — replaces the +extracted runtime after creating a consistent SQLite backup under +`{home}/backups/`. A pending in-app zip under `{home}/updates/` is applied +even when the bundled stamp still matches. The upgraded host then applies +the normal database migrations during startup. Newer extracted runtimes are +never downgraded by an older bundled zip alone; a Windows Setup run still +refreshes because the install stamp changes. PostgreSQL remains externally managed and is not copied by the desktop shell. GitHub Release names follow `FreeOS----.`: diff --git a/desktop/src/build/windows/nsis/project.nsi b/desktop/src/build/windows/nsis/project.nsi index bd981dac..b6e191dd 100644 --- a/desktop/src/build/windows/nsis/project.nsi +++ b/desktop/src/build/windows/nsis/project.nsi @@ -96,6 +96,8 @@ LangString OPENXYOS_FAIL_LOG ${LANG_SIMPCHINESE} "详细日志:%LOCALAPPDATA%\ LangString OPENXYOS_FAIL_LOG ${LANG_ENGLISH} "Logs: %LOCALAPPDATA%\\FreeOS\\openxyos\\start.log and provision.log" LangString UN_FREEOS_RUNNING ${LANG_SIMPCHINESE} "检测到 FreeOS 仍在运行(主程序、主机或本机 openXYOS)。$\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 local openXYOS).$\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?" +LangString INSTALL_FREEOS_RUNNING ${LANG_SIMPCHINESE} "检测到 FreeOS 仍在运行。$\r$\n$\r$\n继续安装将结束这些进程,以便更新桌面程序,并在下次启动时刷新 %USERPROFILE%\.freeos\portable(程序包与界面)。$\r$\n对话与数据库会保留。$\r$\n$\r$\n要继续安装吗?" +LangString INSTALL_FREEOS_RUNNING ${LANG_ENGLISH} "FreeOS is still running.$\r$\n$\r$\nContinuing Setup will stop those processes so the app can be updated, and the next launch can refresh %USERPROFILE%\.freeos\portable (packages and UI).$\r$\nChats and the database are kept.$\r$\n$\r$\nContinue install?" Name "${INFO_PRODUCTNAME}" !ifndef INSTALLER_OUTFILE @@ -122,12 +124,15 @@ FunctionEnd Section !insertmacro wails.setShellContext + !insertmacro wails.stopRunningFreeOSForInstall !insertmacro wails.webview2runtime SetOutPath $INSTDIR !insertmacro wails.files + !insertmacro wails.writeInstallStamp + !insertmacro wails.invalidateExtractedPortable # SetOutPath becomes the shortcut WorkingDirectory. Pin it again so # WebView2's plugin dir cannot leak into Start in:. diff --git a/desktop/src/build/windows/nsis/wails_tools.nsh b/desktop/src/build/windows/nsis/wails_tools.nsh index ca6ec046..ddd5fb61 100644 --- a/desktop/src/build/windows/nsis/wails_tools.nsh +++ b/desktop/src/build/windows/nsis/wails_tools.nsh @@ -25,6 +25,9 @@ !ifndef PRODUCT_EXECUTABLE !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe" !endif +!ifndef INSTALL_STAMP_NAME + !define INSTALL_STAMP_NAME "FREEOS_INSTALL_STAMP" +!endif !ifndef UNINST_KEY_NAME !define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" !endif @@ -328,6 +331,52 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" ${EndIf} !macroend +# Install/reinstall: stop a running shell so Setup can overwrite FreeOS.exe +# and the next launch can refresh ~/.freeos/portable. Silent installs stop +# without asking. LangString INSTALL_FREEOS_RUNNING is in project.nsi. +!macro wails.stopRunningFreeOSForInstall + !insertmacro wails.detectFreeOSProcesses + ${If} $R7 == "1" + IfSilent wailsInstallStop + MessageBox MB_YESNO|MB_ICONEXCLAMATION "$(INSTALL_FREEOS_RUNNING)" IDYES wailsInstallStop + Abort + wailsInstallStop: + !insertmacro wails.stopFreeOSProcesses + ${EndIf} +!macroend + +# Unique per Setup run. The desktop host compares this file with +# {home}/portable/FREEOS_INSTALL_STAMP and re-extracts when they differ, +# even if FREEOS_STAMP inside the bundled zip stayed the same. +!macro wails.writeInstallStamp + FileOpen $0 "$INSTDIR\${INSTALL_STAMP_NAME}" w + FileWrite $0 "${INFO_PRODUCTVERSION}-" + System::Call 'kernel32::GetTickCount()i .r1' + FileWrite $0 "$1" + FileClose $0 +!macroend + +# Clear the extracted runtime stamp so first launch after Setup refreshes +# packages/ and the embedded dashboard. Do not delete octop.db / settings. +# USERPROFILE survives SetShellVarContext all (unlike $PROFILE). +!macro wails.invalidateExtractedPortable + ReadEnvStr $R5 USERPROFILE + ${If} $R5 == "" + StrCpy $R5 "$PROFILE" + ${EndIf} + Delete "$R5\.freeos\portable\FREEOS_STAMP" + Delete "$R5\.octop\portable\FREEOS_STAMP" + ReadEnvStr $R4 FREEOS_HOME + ${If} $R4 != "" + Delete "$R4\portable\FREEOS_STAMP" + ${EndIf} + ReadEnvStr $R4 OCTOP_HOME + ${If} $R4 != "" + Delete "$R4\portable\FREEOS_STAMP" + ${EndIf} + DetailPrint "Marked ~/.freeos/portable for refresh on next launch" +!macroend + # If FreeOS is running: ask first (never kill on Cancel). Yes → close then # force-stop, then uninstall continues. LangString UN_FREEOS_RUNNING is # defined in project.nsi after MUI_LANGUAGE. diff --git a/desktop/src/download.go b/desktop/src/download.go index bae6d5be..b8fc1c02 100644 --- a/desktop/src/download.go +++ b/desktop/src/download.go @@ -74,6 +74,7 @@ func ensurePortable(locale Locale, status func(string)) error { if !launchReady(root) { return fmt.Errorf("portable extract missing launch.py or python under %s", root) } + recordAppliedInstallStamp(root) return nil } @@ -123,8 +124,17 @@ func renamePortable(source string, target string) error { } const portableStampName = "FREEOS_STAMP" +const installStampName = "FREEOS_INSTALL_STAMP" func shouldReplacePortable(root string) bool { + if pendingPortableZip() != "" { + log.Printf("portable: applying pending zip") + return true + } + if installStampRequiresRefresh(root) { + log.Printf("portable: installer stamp changed; refreshing extracted runtime") + return true + } bundledStamp := bundledPortableStamp() if bundledStamp != "" && installedPortableStamp(root) == bundledStamp { return false @@ -134,9 +144,6 @@ func shouldReplacePortable(root string) bool { if err != nil { bundledVersion = "" } - if pendingPortableZip() != "" { - return true - } // Keep a newer in-app FreeOS portable over an older bundled zip. // Do not keep Octop 0.9 / 1.0 leftovers — FreeOS is 0.0.2 and must replace them. if currentVersion != "" && bundledVersion != "" && @@ -147,6 +154,56 @@ func shouldReplacePortable(root string) bool { return true } +func installStampBesideExe() string { + if v, ok := os.LookupEnv("OCTOP_DESKTOP_INSTALL_STAMP_FILE"); ok { + return strings.TrimSpace(v) + } + exe, err := os.Executable() + if err != nil { + return "" + } + dir := filepath.Dir(exe) + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved + } + return filepath.Join(dir, installStampName) +} + +func readStampFile(path string) string { + if path == "" { + return "" + } + data, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +func appliedInstallStamp(root string) string { + return readStampFile(filepath.Join(root, installStampName)) +} + +func installStampRequiresRefresh(root string) bool { + want := readStampFile(installStampBesideExe()) + if want == "" { + return false + } + return appliedInstallStamp(root) != want +} + +func recordAppliedInstallStamp(root string) { + src := installStampBesideExe() + want := readStampFile(src) + if want == "" { + return + } + dest := filepath.Join(root, installStampName) + if err := os.WriteFile(dest, []byte(want+"\n"), 0o644); err != nil { + log.Printf("portable: write %s: %v", dest, err) + } +} + func isOctopLineageRuntime(root string) bool { if installedPortableStamp(root) != "" { return false diff --git a/desktop/src/download_test.go b/desktop/src/download_test.go index d9247f51..276edd7e 100644 --- a/desktop/src/download_test.go +++ b/desktop/src/download_test.go @@ -14,6 +14,16 @@ import ( "time" ) +func TestMain(m *testing.M) { + // Tests that need a Windows-style install stamp set OCTOP_DESKTOP_INSTALL_STAMP_FILE + // themselves. Default empty so leftover files beside the test binary cannot + // force a refresh. + if os.Getenv("OCTOP_DESKTOP_INSTALL_STAMP_FILE") == "" { + _ = os.Setenv("OCTOP_DESKTOP_INSTALL_STAMP_FILE", "") + } + os.Exit(m.Run()) +} + func TestEnsurePortableUsesEmbeddedPackage(t *testing.T) { home := t.TempDir() t.Setenv("OCTOP_HOME", home) @@ -272,6 +282,114 @@ func TestEnsurePortableReplacesOctopLineageWithFreeOS(t *testing.T) { } } +func TestEnsurePortableAppliesPendingEvenWhenBundledStampMatches(t *testing.T) { + home := t.TempDir() + t.Setenv("OCTOP_HOME", home) + t.Setenv("OCTOP_DESKTOP_INSTALL_STAMP_FILE", "") + root := portableDir() + + currentZip := filepath.Join(t.TempDir(), "current.zip") + writeTestGreenZipWithStamp(t, currentZip, "0.0.1", "same-stamp") + if err := unzipGreen(currentZip, root); err != nil { + t.Fatal(err) + } + t.Setenv("OCTOP_DESKTOP_PORTABLE_ZIP", currentZip) + + pendingDir := filepath.Join(home, "updates") + if err := os.MkdirAll(pendingDir, 0o755); err != nil { + t.Fatal(err) + } + pending := filepath.Join(pendingDir, "pending-portable.zip") + writeTestGreenZipWithStamp(t, pending, "0.0.2", "pending-stamp") + + if err := ensurePortable(LocaleZH, func(string) {}); err != nil { + t.Fatal(err) + } + if got := portableVersion(root); got != "0.0.2" { + t.Fatalf("portable version = %q, want 0.0.2 from pending zip", got) + } + if got := installedPortableStamp(root); got != "pending-stamp" { + t.Fatalf("stamp = %q, want pending-stamp", got) + } +} + +func TestEnsurePortableReinstallStampRefreshesMatchingZip(t *testing.T) { + home := t.TempDir() + t.Setenv("OCTOP_HOME", home) + root := portableDir() + + zipPath := filepath.Join(t.TempDir(), "same.zip") + writeTestGreenZipWithStamp(t, zipPath, "0.0.4", "same-payload") + if err := unzipGreen(zipPath, root); err != nil { + t.Fatal(err) + } + stale := filepath.Join(root, "old-dashboard.txt") + if err := os.WriteFile(stale, []byte("stale-ui"), 0o644); err != nil { + t.Fatal(err) + } + + stampFile := filepath.Join(t.TempDir(), installStampName) + if err := os.WriteFile(stampFile, []byte("setup-run-2\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("OCTOP_DESKTOP_INSTALL_STAMP_FILE", stampFile) + t.Setenv("OCTOP_DESKTOP_PORTABLE_ZIP", zipPath) + + if err := ensurePortable(LocaleZH, func(string) {}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Fatalf("reinstall did not replace extracted portable: %v", err) + } + if got := appliedInstallStamp(root); got != "setup-run-2" { + t.Fatalf("applied install stamp = %q, want setup-run-2", got) + } + + keep := filepath.Join(root, "keep-after-match.txt") + if err := os.WriteFile(keep, []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + if err := ensurePortable(LocaleZH, func(string) {}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(keep); err != nil { + t.Fatalf("second launch should skip extract when install stamp matches: %v", err) + } +} + +func TestEnsurePortableClearedRuntimeStampRefreshesSameVersion(t *testing.T) { + home := t.TempDir() + t.Setenv("OCTOP_HOME", home) + t.Setenv("OCTOP_DESKTOP_INSTALL_STAMP_FILE", "") + root := portableDir() + + zipPath := filepath.Join(t.TempDir(), "v004.zip") + writeTestGreenZipWithStamp(t, zipPath, "0.0.4", "build-old") + if err := unzipGreen(zipPath, root); err != nil { + t.Fatal(err) + } + stale := filepath.Join(root, "index.DfMCjOvx.js") + if err := os.WriteFile(stale, []byte("old-dashboard"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(root, portableStampName)); err != nil { + t.Fatal(err) + } + + newZip := filepath.Join(t.TempDir(), "v004-new.zip") + writeTestGreenZipWithStamp(t, newZip, "0.0.4", "build-new") + t.Setenv("OCTOP_DESKTOP_PORTABLE_ZIP", newZip) + if err := ensurePortable(LocaleZH, func(string) {}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Fatalf("cleared FREEOS_STAMP should force same-version refresh: %v", err) + } + if got := installedPortableStamp(root); got != "build-new" { + t.Fatalf("stamp = %q, want build-new", got) + } +} + func TestEnsurePortableReplacesSameVersionWhenStampChanges(t *testing.T) { home := t.TempDir() t.Setenv("OCTOP_HOME", home) diff --git a/tests/unit/desktop/test_nsis_uninstall.py b/tests/unit/desktop/test_nsis_uninstall.py index caf5407b..b92e01c2 100644 --- a/tests/unit/desktop/test_nsis_uninstall.py +++ b/tests/unit/desktop/test_nsis_uninstall.py @@ -46,6 +46,60 @@ def test_uninstall_asks_before_stopping_running_processes() -> None: assert 'taskkill /F /T /IM "${PRODUCT_EXECUTABLE}"' in nsh assert "CloseMainWindow" in nsh assert r"*\portable\*launch.py* run*" in nsh + + +def test_install_stops_running_freeos_and_invalidates_portable() -> None: + nsi = NSI.read_text(encoding="utf-8-sig") + nsh = NSH.read_text(encoding="utf-8") + install = nsi[nsi.index("Section\n") : nsi.index('Section "uninstall"')] + + assert "!insertmacro wails.stopRunningFreeOSForInstall" in install + assert "!insertmacro wails.writeInstallStamp" in install + assert "!insertmacro wails.invalidateExtractedPortable" in install + assert install.index("wails.stopRunningFreeOSForInstall") < install.index("wails.files") + assert install.index("wails.files") < install.index("wails.writeInstallStamp") + assert install.index("wails.writeInstallStamp") < install.index( + "wails.invalidateExtractedPortable" + ) + + assert "LangString INSTALL_FREEOS_RUNNING ${LANG_SIMPCHINESE}" in nsi + assert "LangString INSTALL_FREEOS_RUNNING ${LANG_ENGLISH}" in nsi + assert r"%USERPROFILE%\.freeos\portable" in nsi + assert "refresh" in nsi.lower() or "刷新" in nsi + + stop = nsh[ + nsh.index("!macro wails.stopRunningFreeOSForInstall") : nsh.index( + "!macro wails.writeInstallStamp" + ) + ] + assert "wails.detectFreeOSProcesses" in stop + assert "IfSilent" in stop + assert "MessageBox" in stop + assert "Abort" in stop + assert "wails.stopFreeOSProcesses" in stop + assert "UN_FREEOS_RUNNING" not in stop + + stamp = nsh[ + nsh.index("!macro wails.writeInstallStamp") : nsh.index( + "!macro wails.invalidateExtractedPortable" + ) + ] + assert r"$INSTDIR\${INSTALL_STAMP_NAME}" in stamp + assert "GetTickCount" in stamp + assert "FREEOS_INSTALL_STAMP" in nsh + + invalidate = nsh[ + nsh.index("!macro wails.invalidateExtractedPortable") : nsh.index( + "!macro wails.confirmRunningFreeOS" + ) + ] + assert "ReadEnvStr $R5 USERPROFILE" in invalidate + assert r"$R5\.freeos\portable\FREEOS_STAMP" in invalidate + assert r"$R5\.octop\portable\FREEOS_STAMP" in invalidate + assert "FREEOS_HOME" in invalidate + assert "OCTOP_HOME" in invalidate + assert "octop.db" not in invalidate.lower() or "Do not delete octop.db" in invalidate + assert "RMDir" not in invalidate assert r"*\org-sidecar\*" in nsh @@ -263,6 +317,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 "FREEOS_INSTALL_STAMP" in text + assert "FREEOS_STAMP" in text + assert "%USERPROFILE%\\.freeos\\portable" in text assert "%LOCALAPPDATA%\\FreeOS\\openxyos" in text assert "127.0.0.1:3780" in text assert "tar.exe" in text