Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。

### 文档

Expand Down
39 changes: 30 additions & 9 deletions desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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-<plat>-<version>.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-<kind>-<os>-<arch>-<version>.<ext>`:
Expand Down
5 changes: 5 additions & 0 deletions desktop/src/build/windows/nsis/project.nsi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:.
Expand Down
49 changes: 49 additions & 0 deletions desktop/src/build/windows/nsis/wails_tools.nsh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
63 changes: 60 additions & 3 deletions desktop/src/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand All @@ -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 != "" &&
Expand All @@ -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
Expand Down
118 changes: 118 additions & 0 deletions desktop/src/download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading