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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 在桌面壳里进第一个智能体。组织仍是侧栏里的另一间房间,首次会话不自动走进去。
Expand Down
22 changes: 15 additions & 7 deletions desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down
9 changes: 6 additions & 3 deletions desktop/src/build/windows/nsis/project.nsi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
153 changes: 151 additions & 2 deletions desktop/src/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"sort"
"strconv"
"strings"
"syscall"
"time"
)

Expand Down Expand Up @@ -95,22 +96,53 @@ 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
}
if err := renamePortable(next, root); err != nil {
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 {
Expand All @@ -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"

Expand Down
78 changes: 78 additions & 0 deletions desktop/src/download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"path/filepath"
"runtime"
"strings"
"syscall"
"testing"
"time"
)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/process_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,5 @@ func killPid(pid int) {
}

func killWindowsImageAt(string) {}

func stopPortableHolders(string) {}
29 changes: 29 additions & 0 deletions desktop/src/process_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down
Loading
Loading