From 000559d4b61592d2605ca6d39da63f6d1a9c39ad Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 14:26:46 +0000 Subject: [PATCH] Fix Windows installer launch and NSIS Chinese mojibake. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped v0.0.1 NSIS package could look like a no-op after install: shortcuts inherited the wrong Start-in path, WebView2 tried to write next to FreeOS.exe under Program Files, and the elevated finish-page Exec stamped ~/.freeos / desktop.pid at High integrity so a later unelevated double-click died silently. Pin WorkingDirectory to $INSTDIR, store WebView2 under %LOCALAPPDATA%\FreeOS\WebView2, launch via IShellDispatch2 (explorer fallback), ignore leftover pids that are not FreeOS.exe, and repair a non-writable product home. Finish-page "运行 FreeOS" was UTF-8 decoded as CP1252 (è¿è¡Œ) because Unicode true does not change makensis source charset. Save project.nsi as UTF-8 with BOM and compile with -INPUTCHARSET UTF8. --- CHANGELOG.md | 2 + desktop/README.md | 10 ++- desktop/src/build/windows/Taskfile.yml | 4 +- desktop/src/build/windows/nsis/project.nsi | 29 +++++++-- desktop/src/desktop_log_test.go | 8 ++- desktop/src/home_writable_other.go | 5 ++ desktop/src/home_writable_windows.go | 17 +++++ desktop/src/instance.go | 6 +- desktop/src/instance_other.go | 6 ++ desktop/src/instance_windows.go | 63 +++++++++++++++++++ desktop/src/launch_paths.go | 73 ++++++++++++++++++++++ desktop/src/launch_paths_test.go | 47 ++++++++++++++ desktop/src/main.go | 27 ++++++++ desktop/src/settings.go | 23 +++++-- tests/unit/desktop/test_nsis_uninstall.py | 52 +++++++++++++-- 15 files changed, 348 insertions(+), 24 deletions(-) create mode 100644 desktop/src/home_writable_other.go create mode 100644 desktop/src/home_writable_windows.go create mode 100644 desktop/src/launch_paths.go create mode 100644 desktop/src/launch_paths_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2867484c..0b5589f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ ### 修复 +- Windows NSIS 安装完成后双击桌面 / 开始菜单快捷方式(或 `FreeOS.exe`)能打开窗口:快捷方式工作目录固定为 `$INSTDIR`,WebView2 用户数据写到 `%LOCALAPPDATA%\FreeOS\WebView2`(不再落到 Program Files),结束页用未提权 token 启动以免把 `~/.freeos` 标成 High integrity,并忽略非 `FreeOS.exe` 的残留 `desktop.pid`。 +- Windows NSIS 结束页复选框不再显示 `è¿è¡Œ FreeOS`:`project.nsi` 使用 UTF-8 BOM,`makensis -INPUTCHARSET UTF8`,简体中文 LangString(运行 FreeOS / 卸载确认)按 Unicode 编译。 - Windows NSIS uninstall now stops FreeOS / host / sidecar processes and recursively removes `$INSTDIR` (quoted `RMDir /r`, plus a delayed cleanup after `uninstall.exe` exits). If those processes are still running, uninstall asks first (cancel aborts and leaves them running; confirm closes them then wipes the install dir). User profile data under `%USERPROFILE%\.freeos` / `FREEOS_HOME` / legacy `~/.octop` is kept. Product version remains 0.0.1. - Windows desktop no longer keeps a leftover Octop 0.9 / 1.0 portable under `~/.octop` just because FreeOS is `0.0.1`. Same-version rebuilds replace when `FREEOS_STAMP` changes. - Desktop host now sets `OCTOP_DESKTOP=1` / `FREEOS_DESKTOP=1`. Local session accepts IPv4-mapped loopback and, on desktop, picks an existing admin instead of dead-ending on the login form. diff --git a/desktop/README.md b/desktop/README.md index b98391d2..a8e14529 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -28,7 +28,10 @@ Same precedence as the FreeOS CLI/server: 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). Uncheck to skip. +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`. ## Windows uninstall @@ -48,8 +51,9 @@ rather than cutting a new tag. were written under `$INSTDIR`, and any other installer-owned tree there - Start Menu, Desktop, and Startup shortcuts created by the installer - Add/Remove Programs registry key and the autostart Run value -- Program-owned WebView2 / Wails cache under `%AppData%\FreeOS.exe` and - `%LOCALAPPDATA%\FreeOS` +- Program-owned WebView2 / Wails cache under `%AppData%\FreeOS.exe`, + `%LOCALAPPDATA%\FreeOS.exe.WebView2`, and `%LOCALAPPDATA%\FreeOS` + (including `WebView2\`) **Keeps** diff --git a/desktop/src/build/windows/Taskfile.yml b/desktop/src/build/windows/Taskfile.yml index 733da564..37dedb46 100644 --- a/desktop/src/build/windows/Taskfile.yml +++ b/desktop/src/build/windows/Taskfile.yml @@ -82,9 +82,9 @@ tasks: New-Item -ItemType Directory -Force '{{.ROOT_DIR}}/{{.BIN_DIR}}' | Out-Null" - | {{if eq OS "windows"}} - makensis {{.VERSION_NSIS_DEFINE}}-DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" -DINSTALLER_OUTFILE="{{.ROOT_DIR}}\{{.ARCHIVE}}" project.nsi + makensis -INPUTCHARSET UTF8 {{.VERSION_NSIS_DEFINE}}-DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" -DINSTALLER_OUTFILE="{{.ROOT_DIR}}\{{.ARCHIVE}}" project.nsi {{else}} - makensis {{.VERSION_NSIS_DEFINE}}-DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" -DINSTALLER_OUTFILE="{{.ROOT_DIR}}/{{.ARCHIVE}}" project.nsi + makensis -INPUTCHARSET UTF8 {{.VERSION_NSIS_DEFINE}}-DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" -DINSTALLER_OUTFILE="{{.ROOT_DIR}}/{{.ARCHIVE}}" project.nsi {{end}} vars: ARCH: '{{.ARCH | default ARCH}}' diff --git a/desktop/src/build/windows/nsis/project.nsi b/desktop/src/build/windows/nsis/project.nsi index 38c8b939..3282ecfe 100644 --- a/desktop/src/build/windows/nsis/project.nsi +++ b/desktop/src/build/windows/nsis/project.nsi @@ -1,9 +1,14 @@ -Unicode true +Unicode true # FreeOS desktop NSIS installer. # Built by `wails3 task package` on a Windows runner: -# makensis -DARG_WAILS_AMD64_BINARY=..\..\..\bin\FreeOS.exe project.nsi -# makensis -DARG_WAILS_ARM64_BINARY=..\..\..\bin\FreeOS.exe project.nsi +# makensis -INPUTCHARSET UTF8 -DARG_WAILS_AMD64_BINARY=..\..\..\bin\FreeOS.exe project.nsi +# makensis -INPUTCHARSET UTF8 -DARG_WAILS_ARM64_BINARY=..\..\..\bin\FreeOS.exe project.nsi +# +# Unicode true makes a Unicode installer; it does NOT change how this .nsi is +# decoded. GitHub windows-latest is en-US (ACP = CP1252). Without a UTF-8 BOM +# and -INPUTCHARSET UTF8, LangString 运行 becomes the finish-page mojibake +# "è¿è¡Œ FreeOS". Keep this file UTF-8 with BOM. !include "wails_tools.nsh" @@ -82,9 +87,9 @@ Section !insertmacro wails.files - # SetOutPath becomes the shortcut working directory. Pin it again so + # SetOutPath becomes the shortcut WorkingDirectory. Pin it again so # WebView2's plugin dir cannot leak into Start in:. - SetOutPath $INSTDIR + SetOutPath "$INSTDIR" CreateShortCut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" "" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 SW_SHOWNORMAL CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" "" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 SW_SHOWNORMAL @@ -120,5 +125,17 @@ SectionEnd Function LaunchFreeOS SetOutPath "$INSTDIR" - Exec '"$INSTDIR\${PRODUCT_EXECUTABLE}"' + ; Installer is admin. Exec/CreateProcess inherits that token and the + ; first run would write ~/.freeos + WebView2 as High integrity — a later + ; unelevated shortcut click then does nothing. IShellDispatch2 runs as + ; the explorer (medium IL) token. Working directory stays $INSTDIR. + System::Call "ole32::CoInitialize(i 0)" + System::Call 'ole32::CoCreateInstance(g "{13709620-C279-11CE-A49E-444553540000}",i 0,i 1,g "{A4C6892C-3BA9-11d2-9DEA-00C04FB16162}",*i .r0) i .r1' + ${If} $1 == 0 + ${AndIf} $0 != 0 + System::Call '$0->31(w "$INSTDIR\${PRODUCT_EXECUTABLE}", w "", w "$INSTDIR", w "open", i 1)' + System::Call "$0->2()" + ${Else} + Exec '"$WINDIR\explorer.exe" "$INSTDIR\${PRODUCT_EXECUTABLE}"' + ${EndIf} FunctionEnd diff --git a/desktop/src/desktop_log_test.go b/desktop/src/desktop_log_test.go index 4ea37e6b..2945de05 100644 --- a/desktop/src/desktop_log_test.go +++ b/desktop/src/desktop_log_test.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "runtime" "strconv" "strings" "testing" @@ -67,7 +68,12 @@ func TestClaimDesktopInstanceDetectsLiveLock(t *testing.T) { if err := os.WriteFile(desktopLockPath(), []byte(strconv.Itoa(parent)), 0o644); err != nil { t.Fatal(err) } - if !claimDesktopInstance() { + blocked := claimDesktopInstance() + if runtime.GOOS == "windows" { + if blocked { + t.Fatal("live pid that is not FreeOS.exe must not block Windows launch") + } + } else if !blocked { t.Fatal("lock held by another live pid should be detected") } clearDesktopPid() diff --git a/desktop/src/home_writable_other.go b/desktop/src/home_writable_other.go new file mode 100644 index 00000000..6a884d13 --- /dev/null +++ b/desktop/src/home_writable_other.go @@ -0,0 +1,5 @@ +//go:build !windows + +package main + +func repairProductHomeAccess(string) {} diff --git a/desktop/src/home_writable_windows.go b/desktop/src/home_writable_windows.go new file mode 100644 index 00000000..93d5b84e --- /dev/null +++ b/desktop/src/home_writable_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package main + +import "os/exec" + +func repairProductHomeAccess(home string) { + // An elevated finish-page launch can stamp ~/.freeos with a High + // mandatory integrity label. Medium-IL shortcut clicks then cannot + // write logs, desktop.pid, or the portable extract. + grant := exec.Command("icacls", home, "/grant", "*S-1-5-32-545:(OI)(CI)M", "/T") + hideConsole(grant) + _ = grant.Run() + integrity := exec.Command("icacls", home, "/setintegritylevel", "(OI)(CI)M", "/T") + hideConsole(integrity) + _ = integrity.Run() +} diff --git a/desktop/src/instance.go b/desktop/src/instance.go index 033a1315..a56eed04 100644 --- a/desktop/src/instance.go +++ b/desktop/src/instance.go @@ -32,12 +32,16 @@ func writeDesktopPid() error { func clearDesktopPid() { _ = os.Remove(desktopLockPath()) + releaseDesktopInstanceLock() } // claimDesktopInstance returns true when another live desktop process holds the lock. func claimDesktopInstance() bool { + if otherDesktopInstanceHeld() { + return true + } pid := readDesktopPid() - if pid > 0 && pid != os.Getpid() && pidAlive(pid) { + if pid > 0 && pid != os.Getpid() && pidAlive(pid) && pidLooksLikeDesktopShell(pid) { return true } if err := writeDesktopPid(); err != nil { diff --git a/desktop/src/instance_other.go b/desktop/src/instance_other.go index eb7b7051..336282e2 100644 --- a/desktop/src/instance_other.go +++ b/desktop/src/instance_other.go @@ -15,3 +15,9 @@ func pidAlive(pid int) bool { func activateExistingInstance() bool { return false } + +func otherDesktopInstanceHeld() bool { return false } + +func pidLooksLikeDesktopShell(int) bool { return true } + +func releaseDesktopInstanceLock() {} diff --git a/desktop/src/instance_windows.go b/desktop/src/instance_windows.go index 8b454d00..c23159b3 100644 --- a/desktop/src/instance_windows.go +++ b/desktop/src/instance_windows.go @@ -4,12 +4,17 @@ package main import ( "log" + "os" + "path/filepath" + "strings" "syscall" "unsafe" "golang.org/x/sys/windows" ) +var desktopMutex windows.Handle + func pidAlive(pid int) bool { h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) if err != nil { @@ -53,3 +58,61 @@ func activateExistingInstance() bool { log.Printf("activated existing FreeOS window") return true } + +func otherDesktopInstanceHeld() bool { + name, err := windows.UTF16PtrFromString("Local\\FreeOS-desktop-instance") + if err != nil { + return false + } + h, err := windows.CreateMutex(nil, false, name) + if err == windows.ERROR_ALREADY_EXISTS { + if desktopMutex != 0 { + if h != 0 && h != desktopMutex { + _ = windows.CloseHandle(h) + } + return false + } + if h != 0 { + _ = windows.CloseHandle(h) + } + return true + } + if err != nil { + return false + } + desktopMutex = h + return false +} + +func releaseDesktopInstanceLock() { + if desktopMutex != 0 { + _ = windows.CloseHandle(desktopMutex) + desktopMutex = 0 + } +} + +func pidLooksLikeDesktopShell(pid int) bool { + name := strings.ToLower(pidImageBase(pid)) + if name == "" { + return false + } + want := "freeos.exe" + if exe, err := os.Executable(); err == nil { + want = strings.ToLower(filepath.Base(exe)) + } + return name == want +} + +func pidImageBase(pid int) string { + h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return "" + } + defer windows.CloseHandle(h) + var buf [windows.MAX_PATH]uint16 + size := uint32(len(buf)) + if err := windows.QueryFullProcessImageName(h, 0, &buf[0], &size); err != nil { + return "" + } + return filepath.Base(windows.UTF16ToString(buf[:size])) +} diff --git a/desktop/src/launch_paths.go b/desktop/src/launch_paths.go new file mode 100644 index 00000000..674e8bb1 --- /dev/null +++ b/desktop/src/launch_paths.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "runtime" + "strings" +) + +func pinWorkingDirectory() { + exe, err := os.Executable() + if err != nil { + return + } + dir := filepath.Dir(exe) + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved + } + if err := os.Chdir(dir); err != nil { + log.Printf("chdir %s: %v", dir, err) + } +} + +func userProfileDir() string { + if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { + return home + } + if v := strings.TrimSpace(os.Getenv("USERPROFILE")); v != "" { + return v + } + drive, path := os.Getenv("HOMEDRIVE"), os.Getenv("HOMEPATH") + if drive != "" && path != "" { + return drive + path + } + return "" +} + +func webviewUserDataPath() string { + if runtime.GOOS != "windows" { + return "" + } + base := strings.TrimSpace(os.Getenv("LOCALAPPDATA")) + if base == "" { + if prof := userProfileDir(); prof != "" { + base = filepath.Join(prof, "AppData", "Local") + } else { + return filepath.Join(productHome(), "WebView2") + } + } + return filepath.Join(base, "FreeOS", "WebView2") +} + +func ensureProductHomeWritable() error { + home := productHome() + if err := os.MkdirAll(home, 0o755); err != nil { + return err + } + probe := filepath.Join(home, ".write-probe") + if err := os.WriteFile(probe, []byte("ok"), 0o644); err == nil { + _ = os.Remove(probe) + return nil + } else { + firstErr := err + repairProductHomeAccess(home) + if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil { + return fmt.Errorf("cannot write to %s: %w", home, firstErr) + } + _ = os.Remove(probe) + return nil + } +} diff --git a/desktop/src/launch_paths_test.go b/desktop/src/launch_paths_test.go new file mode 100644 index 00000000..5b043e37 --- /dev/null +++ b/desktop/src/launch_paths_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestProductHomeIsAlwaysAbsolute(t *testing.T) { + t.Setenv("FREEOS_HOME", "") + t.Setenv("OCTOP_HOME", "") + got := productHome() + if !filepath.IsAbs(got) { + t.Fatalf("productHome() = %q, want an absolute path", got) + } +} + +func TestWebviewUserDataPathWindowsLayout(t *testing.T) { + if runtime.GOOS != "windows" { + if got := webviewUserDataPath(); got != "" { + t.Fatalf("non-windows webview path = %q", got) + } + return + } + t.Setenv("LOCALAPPDATA", filepath.Join(t.TempDir(), "local")) + got := webviewUserDataPath() + if filepath.Base(filepath.Dir(got)) != "FreeOS" || filepath.Base(got) != "WebView2" { + t.Fatalf("webviewUserDataPath() = %q", got) + } + if !filepath.IsAbs(got) { + t.Fatalf("webview path must be absolute: %q", got) + } +} + +func TestEnsureProductHomeWritableCreatesDir(t *testing.T) { + root := t.TempDir() + home := filepath.Join(root, "nested-home") + t.Setenv("FREEOS_HOME", home) + t.Setenv("OCTOP_HOME", "") + if err := ensureProductHomeWritable(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(home); err != nil { + t.Fatal(err) + } +} diff --git a/desktop/src/main.go b/desktop/src/main.go index d7164748..13a7a096 100644 --- a/desktop/src/main.go +++ b/desktop/src/main.go @@ -312,7 +312,26 @@ func (a *App) requestQuit() { } func main() { + defer func() { + if rec := recover(); rec != nil { + showFatalError("FreeOS", fmt.Sprintf("%v", rec)) + } + }() + pinWorkingDirectory() + if err := ensureProductHomeWritable(); err != nil { + showFatalError("FreeOS", err.Error()) + return + } initDesktopLog() + if exe, err := os.Executable(); err == nil { + cwd, _ := os.Getwd() + log.Printf("launch exe=%s cwd=%s home=%s", exe, cwd, productHome()) + } + if p := webviewUserDataPath(); p != "" { + if err := os.MkdirAll(p, 0o755); err != nil { + log.Printf("webview user data dir: %v", err) + } + } if claimDesktopInstance() { locale := LocaleEN if data, err := os.ReadFile(settingsPath()); err == nil { @@ -347,6 +366,14 @@ func main() { }, Windows: application.WindowsOptions{ DisableQuitOnLastWindowClosed: true, + WebviewUserDataPath: webviewUserDataPath(), + }, + PanicHandler: func(details *application.PanicDetails) { + log.Printf("panic: %+v", details) + showFatalError("FreeOS", fmt.Sprintf("%v", details)) + }, + ErrorHandler: func(err error) { + logStartupError("wails", err) }, Linux: application.LinuxOptions{ DisableQuitOnLastWindowClosed: true, diff --git a/desktop/src/settings.go b/desktop/src/settings.go index ba1a5907..abdeae00 100644 --- a/desktop/src/settings.go +++ b/desktop/src/settings.go @@ -37,14 +37,14 @@ func defaultSettings() Settings { func productHome() string { if v := os.Getenv("FREEOS_HOME"); v != "" { - return v + return absProductHome(v) } if v := os.Getenv("OCTOP_HOME"); v != "" { - return v + return absProductHome(v) } - home, err := os.UserHomeDir() - if err != nil { - return ".freeos" + home := userProfileDir() + if home == "" { + return filepath.Join(os.TempDir(), "FreeOS") } freeos := filepath.Join(home, ".freeos") octop := filepath.Join(home, ".octop") @@ -57,6 +57,19 @@ func productHome() string { return freeos } +func absProductHome(p string) string { + if filepath.IsAbs(p) { + return filepath.Clean(p) + } + if home := userProfileDir(); home != "" { + return filepath.Join(home, p) + } + if abs, err := filepath.Abs(p); err == nil { + return abs + } + return p +} + // octopHome is the historical name used throughout the desktop shell. // It now resolves FREEOS_HOME first, then OCTOP_HOME, then ~/.freeos. func octopHome() string { diff --git a/tests/unit/desktop/test_nsis_uninstall.py b/tests/unit/desktop/test_nsis_uninstall.py index feb7f853..e388b86f 100644 --- a/tests/unit/desktop/test_nsis_uninstall.py +++ b/tests/unit/desktop/test_nsis_uninstall.py @@ -17,7 +17,7 @@ def _uninstall_section(text: str) -> str: def test_uninstall_asks_before_stopping_running_processes() -> None: - nsi = NSI.read_text(encoding="utf-8") + nsi = NSI.read_text(encoding="utf-8-sig") nsh = NSH.read_text(encoding="utf-8") assert "Function un.onInit" in nsi @@ -48,7 +48,7 @@ def test_uninstall_asks_before_stopping_running_processes() -> None: def test_uninstall_stops_processes_then_wipes_instdir() -> None: - nsi = NSI.read_text(encoding="utf-8") + nsi = NSI.read_text(encoding="utf-8-sig") nsh = NSH.read_text(encoding="utf-8") uninstall = _uninstall_section(nsi) @@ -70,11 +70,11 @@ def test_uninstall_does_not_wipe_profile_homes() -> None: assert "FREEOS_HOME" in nsh assert r'IfFileExists "$INSTDIR\User Data"' in nsh assert r'IfFileExists "$INSTDIR\userdata"' in nsh - assert ".freeos" not in _uninstall_section(NSI.read_text(encoding="utf-8")) + assert ".freeos" not in _uninstall_section(NSI.read_text(encoding="utf-8-sig")) def test_uninstall_removes_shortcuts_and_program_cache() -> None: - uninstall = _uninstall_section(NSI.read_text(encoding="utf-8")) + uninstall = _uninstall_section(NSI.read_text(encoding="utf-8-sig")) assert 'Delete "$SMPROGRAMS\\${INFO_PRODUCTNAME}.lnk"' in uninstall assert 'Delete "$DESKTOP\\${INFO_PRODUCTNAME}.lnk"' in uninstall assert 'Delete "$SMSTARTUP\\${INFO_PRODUCTNAME}.lnk"' in uninstall @@ -83,7 +83,7 @@ def test_uninstall_removes_shortcuts_and_program_cache() -> None: def test_finish_page_run_defaults_checked() -> None: - nsi = NSI.read_text(encoding="utf-8") + 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 @@ -92,7 +92,47 @@ def test_finish_page_run_defaults_checked() -> None: assert "!define MUI_FINISHPAGE_RUN_NOTCHECKED" not in nsi launch = nsi[nsi.index("Function LaunchFreeOS") :] assert 'SetOutPath "$INSTDIR"' in launch - assert "Exec '\"$INSTDIR\\${PRODUCT_EXECUTABLE}\"'" in launch + # 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 + assert "Exec '\"$INSTDIR\\${PRODUCT_EXECUTABLE}\"'" not in launch + + +def test_shortcut_working_directory_is_instdir() -> None: + nsi = NSI.read_text(encoding="utf-8-sig") + install = nsi[nsi.index("Section\n") : nsi.index('Section "uninstall"')] + create = install.index("CreateShortCut") + pinned = install.rfind("SetOutPath", 0, create) + assert pinned != -1 + window = install[pinned:create] + assert "$INSTDIR" in window + assert "pluginsdir" not in window + + +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 + mojibake = "运行".encode().decode("cp1252", errors="replace") + assert mojibake not in text + task = (REPO / "desktop" / "src" / "build" / "windows" / "Taskfile.yml").read_text( + encoding="utf-8" + ) + assert task.count("-INPUTCHARSET UTF8") >= 2 + + +def test_packaged_windows_entry_is_freeos_exe() -> None: + nsh = NSH.read_text(encoding="utf-8") + assert 'File "/oname=${PRODUCT_EXECUTABLE}"' in nsh + assert "${ARG_WAILS_AMD64_BINARY}" in nsh + assert "${ARG_WAILS_ARM64_BINARY}" in nsh + embed = (REPO / "desktop" / "src" / "embed_portable_embedded.go").read_text(encoding="utf-8") + assert "bundled/portable.zip" in embed + launch = (REPO / "desktop" / "portable" / "templates" / "launch.py").read_text(encoding="utf-8") + assert "addsitedir" in launch or "site.addsitedir" in launch def test_desktop_readme_documents_uninstall_keep_vs_remove() -> None: