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 @@ -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.
Expand Down
10 changes: 7 additions & 3 deletions desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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**

Expand Down
4 changes: 2 additions & 2 deletions desktop/src/build/windows/Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}}'
Expand Down
29 changes: 23 additions & 6 deletions desktop/src/build/windows/nsis/project.nsi
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
8 changes: 7 additions & 1 deletion desktop/src/desktop_log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
Expand Down Expand Up @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions desktop/src/home_writable_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
//go:build !windows

package main

func repairProductHomeAccess(string) {}
17 changes: 17 additions & 0 deletions desktop/src/home_writable_windows.go
Original file line number Diff line number Diff line change
@@ -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()
}
6 changes: 5 additions & 1 deletion desktop/src/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions desktop/src/instance_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
63 changes: 63 additions & 0 deletions desktop/src/instance_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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]))
}
73 changes: 73 additions & 0 deletions desktop/src/launch_paths.go
Original file line number Diff line number Diff line change
@@ -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
}
}
47 changes: 47 additions & 0 deletions desktop/src/launch_paths_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading