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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,14 @@

## [Unreleased]

### 修复

- 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 NSIS finish page shows a “运行 FreeOS” checkbox, checked by default, and launches from `$INSTDIR`.
- Desktop shell sets `OCTOP_DESKTOP=1` on the bundled host so first-run can treat the process as the Wails app.
- First launch on desktop / loopback opens a local guest session — no login wall. Register or sign in only when saving, exporting, or publishing to an account.
- Login, splash, favicons, and desktop icons use the circular XYAI mark. Product version remains 0.0.1.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Operator detail: [docs/asset-loop.md](docs/asset-loop.md).
3. 打开 **FreeOS**。第一次启动会解压内置运行环境(可能要一两分钟),然后直接进入可用会话,无需先登录。
4. 保存、导出或发布到账号时再注册或登录。组织控制台(openXYOS)已随安装包内置,无需再装 Node;侧栏 **Organization** 默认打开。

数据目录默认是 `%USERPROFILE%\.freeos`(可用环境变量 `FREEOS_HOME` 改)。旧版 Octop 的 `~/.octop` 仍会被识别。
数据目录默认是 `%USERPROFILE%\.freeos`(可用环境变量 `FREEOS_HOME` 改)。旧版 Octop 的 `~/.octop` 仍会被识别。卸载安装包会清空安装目录(默认为 `Program Files\FreeOS`)并删除快捷方式,但**不会**删除该用户数据目录;详见 [desktop/README.md](desktop/README.md#windows-uninstall)。

安装包由 CI 工作流 **FreeOS Desktop Package**(文件名仍是 `.github/workflows/octop-desktop.yml`,给现有发版脚本用)在 Windows runner 上打出来。本仓库的云环境打不出 `.exe`;合并后由该 job 产出。

Expand Down
39 changes: 39 additions & 0 deletions desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,45 @@ Same precedence as the FreeOS CLI/server:
- Organization sidecar data → `{home}/org-os/`
- Shell prefs → `{home}/desktop-settings.json`

## 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). Uncheck to skip.

## Windows uninstall

The NSIS uninstaller (Settings → Apps, or `uninstall.exe` in the install
folder) removes program-owned files. If FreeOS (or its host / org-sidecar)
is still running, it **asks** before continuing (Chinese / English). Cancel
aborts uninstall and leaves processes running. Confirm closes them
(graceful, then force) and then wipes the install directory. Version stays
`0.0.1`; rebuilds replace the existing GitHub Release `v0.0.1` assets
rather than cutting a new tag.

**Removes**

- The install directory (`Program Files\FreeOS` by default, or the folder
chosen at install time): `FreeOS.exe`, `uninstall.exe`, WebView2 folders
created next to the exe, extracted portable / org-sidecar leftovers if they
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`

**Keeps**

- `%USERPROFILE%\.freeos` (or `FREEOS_HOME` / `OCTOP_HOME` / legacy `~/.octop`):
workspaces, `octop.db`, memories, settings, logs, and the extracted portable
runtime under `{home}/portable/`
- Documented exceptions **inside** the install directory, if you created them:
`User Data` or `userdata`. The default app never writes user content there.

Do not point `FREEOS_HOME` at a path inside the install directory — uninstall
would treat that tree as program files (unless it is named `User Data` /
`userdata`).

## What the installer starts

On first open the shell:
Expand Down
27 changes: 24 additions & 3 deletions desktop/src/build/windows/nsis/project.nsi
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ ManifestDPIAware true
!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.
!define MUI_FINISHPAGE_RUN "$INSTDIR\${PRODUCT_EXECUTABLE}"
!define MUI_FINISHPAGE_RUN_TEXT "$(FINISH_RUN)"
!define MUI_FINISHPAGE_RUN_FUNCTION LaunchFreeOS

!insertmacro MUI_PAGE_WELCOME
Expand All @@ -41,6 +45,11 @@ ManifestDPIAware true
!insertmacro MUI_LANGUAGE "English"
!insertmacro MUI_RESERVEFILE_LANGDLL

LangString FINISH_RUN ${LANG_SIMPCHINESE} "运行 FreeOS"
LangString FINISH_RUN ${LANG_ENGLISH} "Run FreeOS"
LangString UN_FREEOS_RUNNING ${LANG_SIMPCHINESE} "检测到 FreeOS 仍在运行(主程序、主机或组织侧车)。$\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 organization sidecar).$\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?"

Name "${INFO_PRODUCTNAME}"
!ifndef INSTALLER_OUTFILE
!define INSTALLER_OUTFILE "..\..\..\bin\${INFO_PROJECTNAME}-desktop-windows-${ARCH}-${INFO_PRODUCTVERSION}.exe"
Expand All @@ -60,6 +69,10 @@ Function .onInit
!insertmacro wails.checkArchitecture
FunctionEnd

Function un.onInit
!insertmacro wails.confirmRunningFreeOS
FunctionEnd

Section
!insertmacro wails.setShellContext

Expand All @@ -83,21 +96,29 @@ SectionEnd

Section "uninstall"
!insertmacro wails.setShellContext
; Processes were already confirmed + stopped in un.onInit when present.

; Program-owned WebView2 / Wails cache — not FREEOS_HOME user data.
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}"

RMDir /r $INSTDIR
RMDir /r "$LOCALAPPDATA\${PRODUCT_EXECUTABLE}.WebView2"
RMDir /r "$LOCALAPPDATA\${INFO_PRODUCTNAME}"

Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
Delete "$SMSTARTUP\${INFO_PRODUCTNAME}.lnk"

SetRegView 64
DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${INFO_PRODUCTNAME}"
DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${INFO_PROJECTNAME}"

!insertmacro wails.unassociateFiles
!insertmacro wails.unassociateCustomProtocols

!insertmacro wails.deleteUninstaller
!insertmacro wails.wipeInstallDir
SectionEnd

Function LaunchFreeOS
SetOutPath $INSTDIR
SetOutPath "$INSTDIR"
Exec '"$INSTDIR\${PRODUCT_EXECUTABLE}"'
FunctionEnd
92 changes: 92 additions & 0 deletions desktop/src/build/windows/nsis/wails_tools.nsh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
!include "x64.nsh"
!include "WinVer.nsh"
!include "FileFunc.nsh"
!include "LogicLib.nsh"

!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "FreeOS"
Expand Down Expand Up @@ -242,3 +243,94 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
!macro wails.unassociateCustomProtocols
; No custom protocols
!macroend

# Shared filter: desktop shell, anything under $INSTDIR, portable host
# (python + launch.py run), and org-sidecar node. $R7 is "1" when any match.
!macro wails.detectFreeOSProcesses
StrCpy $R7 "0"
nsExec::Exec 'powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$$ErrorActionPreference=''SilentlyContinue''; $$n = @(Get-CimInstance Win32_Process | Where-Object { ($$_.Name -eq ''${PRODUCT_EXECUTABLE}'') -or ($$_.ExecutablePath -like ''$INSTDIR*'') -or ($$_.CommandLine -like ''*\portable\*launch.py* run*'') -or ($$_.ExecutablePath -like ''*\org-sidecar\*'') }).Count; if ($$n -gt 0) { exit 11 } else { exit 0 }"'
Pop $0
${If} $0 == 11
StrCpy $R7 "1"
${Else}
nsExec::Exec 'cmd.exe /C tasklist /FI "IMAGENAME eq ${PRODUCT_EXECUTABLE}" | find /I "${PRODUCT_EXECUTABLE}"'
Pop $0
${If} $0 == 0
StrCpy $R7 "1"
${EndIf}
${EndIf}
!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.
!macro wails.confirmRunningFreeOS
!insertmacro wails.detectFreeOSProcesses
${If} $R7 == "1"
MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$(UN_FREEOS_RUNNING)" IDYES wailsConfirmKill
Abort
wailsConfirmKill:
!insertmacro wails.stopFreeOSProcesses
${EndIf}
!macroend

# Close the shell / host / sidecar. Caller must have confirmed when they
# were running. Graceful CloseMainWindow / taskkill, then force leftovers.
!macro wails.stopFreeOSProcesses
DetailPrint "Stopping FreeOS processes..."
nsExec::ExecToLog 'taskkill /T /IM "${PRODUCT_EXECUTABLE}"'
Pop $0
nsExec::ExecToLog 'powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$$ErrorActionPreference=''SilentlyContinue''; $$sel = { ($$_.Name -eq ''${PRODUCT_EXECUTABLE}'') -or ($$_.ExecutablePath -like ''$INSTDIR*'') -or ($$_.CommandLine -like ''*\portable\*launch.py* run*'') -or ($$_.ExecutablePath -like ''*\org-sidecar\*'') }; Get-CimInstance Win32_Process | Where-Object $$sel | ForEach-Object { try { $$p = Get-Process -Id $$_.ProcessId; [void]$$p.CloseMainWindow() } catch {} }; Start-Sleep -Seconds 2; Get-CimInstance Win32_Process | Where-Object $$sel | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force }"'
Pop $0
nsExec::ExecToLog 'taskkill /F /T /IM "${PRODUCT_EXECUTABLE}"'
Pop $0
Sleep 1500
!macroend

# Recursively remove the install directory. User profile homes stay intact:
# %USERPROFILE%\.freeos, FREEOS_HOME / OCTOP_HOME, and legacy ~/.octop.
# Documented exceptions inside $INSTDIR: "User Data" and "userdata".
!macro wails.wipeInstallDir
StrCmp $INSTDIR "" wailsWipeSkip
StrCmp $INSTDIR "$PROFILE" wailsWipeSkip
StrCmp $INSTDIR "$PROFILE\.freeos" wailsWipeSkip
StrCmp $INSTDIR "$PROFILE\.octop" wailsWipeSkip
StrCmp $INSTDIR "$WINDIR" wailsWipeSkip
StrCmp $INSTDIR "$SYSDIR" wailsWipeSkip
StrCmp $INSTDIR "$PROGRAMFILES" wailsWipeSkip
StrCmp $INSTDIR "$PROGRAMFILES64" wailsWipeSkip

; RMDir cannot remove the current working directory.
SetOutPath "$TEMP"
RMDir /r "$TEMP\FreeOS-keep-UserData"
RMDir /r "$TEMP\FreeOS-keep-userdata"

StrCpy $R8 ""
StrCpy $R9 ""
IfFileExists "$INSTDIR\User Data" 0 +3
Rename "$INSTDIR\User Data" "$TEMP\FreeOS-keep-UserData"
StrCpy $R8 "1"
IfFileExists "$INSTDIR\userdata" 0 +3
Rename "$INSTDIR\userdata" "$TEMP\FreeOS-keep-userdata"
StrCpy $R9 "1"

RMDir /r "$INSTDIR"

Delete /REBOOTOK "$INSTDIR\${PRODUCT_EXECUTABLE}"
Delete /REBOOTOK "$INSTDIR\uninstall.exe"

${If} $R8 == "1"
${OrIf} $R9 == "1"
CreateDirectory "$INSTDIR"
${If} $R8 == "1"
Rename "$TEMP\FreeOS-keep-UserData" "$INSTDIR\User Data"
${EndIf}
${If} $R9 == "1"
Rename "$TEMP\FreeOS-keep-userdata" "$INSTDIR\userdata"
${EndIf}
${Else}
IfFileExists "$INSTDIR\*.*" 0 wailsWipeSkip
Exec '"$SYSDIR\cmd.exe" /C ping 127.0.0.1 -n 3 -w 1000 > nul & rmdir /s /q "$INSTDIR"'
${EndIf}
wailsWipeSkip:
!macroend
1 change: 1 addition & 0 deletions desktop/src/process.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func hostLaunchEnv(root string, port int) map[string]string {
"FREEOS_ORG_SIDECAR_PORT": strconv.Itoa(defaultSidecarPort),
"OPENXYOS_BASE_URL": sidecarURL(),
"OCTOP_PORT": strconv.Itoa(port),
"OCTOP_DESKTOP": "1",
}
}

Expand Down
3 changes: 3 additions & 0 deletions desktop/src/settings_home_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ func TestHostLaunchEnvSetsFreeosHomeAndOrgEnable(t *testing.T) {
if env["FREEOS_ORG_SIDECAR_URL"] != "http://127.0.0.1:3780" {
t.Fatalf("sidecar url: %+v", env)
}
if env["OCTOP_DESKTOP"] != "1" {
t.Fatalf("desktop flag: %+v", env)
}
}

func TestSidecarReadyRequiresNodeAndServer(t *testing.T) {
Expand Down
110 changes: 110 additions & 0 deletions tests/unit/desktop/test_nsis_uninstall.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Windows NSIS uninstall must wipe $INSTDIR and keep the user profile home."""

from __future__ import annotations

from pathlib import Path

REPO = Path(__file__).resolve().parents[3]
NSI = REPO / "desktop" / "src" / "build" / "windows" / "nsis" / "project.nsi"
NSH = REPO / "desktop" / "src" / "build" / "windows" / "nsis" / "wails_tools.nsh"
DESKTOP_README = REPO / "desktop" / "README.md"


def _uninstall_section(text: str) -> str:
start = text.index('Section "uninstall"')
end = text.index("SectionEnd", start)
return text[start:end]


def test_uninstall_asks_before_stopping_running_processes() -> None:
nsi = NSI.read_text(encoding="utf-8")
nsh = NSH.read_text(encoding="utf-8")

assert "Function un.onInit" in nsi
assert "!insertmacro wails.confirmRunningFreeOS" in nsi
assert "MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2" in nsh
assert "Abort" in nsh
assert "LangString UN_FREEOS_RUNNING ${LANG_SIMPCHINESE}" in nsi
assert "LangString UN_FREEOS_RUNNING ${LANG_ENGLISH}" in nsi
assert "仍在运行" in nsi
assert "still running" in nsi
assert "%USERPROFILE%\\.freeos" in nsi

# Detect belonging processes, then ask; never force-kill before the dialog.
confirm = nsh[
nsh.index("!macro wails.confirmRunningFreeOS") : nsh.index(
"!macro wails.stopFreeOSProcesses"
)
]
assert "wails.detectFreeOSProcesses" in confirm
assert "MessageBox" in confirm
assert "Abort" in confirm
assert "taskkill /F" not in confirm
assert 'taskkill /T /IM "${PRODUCT_EXECUTABLE}"' in nsh
assert 'taskkill /F /T /IM "${PRODUCT_EXECUTABLE}"' in nsh
assert "CloseMainWindow" in nsh
assert r"*\portable\*launch.py* run*" in nsh
assert r"*\org-sidecar\*" in nsh


def test_uninstall_stops_processes_then_wipes_instdir() -> None:
nsi = NSI.read_text(encoding="utf-8")
nsh = NSH.read_text(encoding="utf-8")
uninstall = _uninstall_section(nsi)

assert "!insertmacro wails.wipeInstallDir" in uninstall
assert "!insertmacro wails.confirmRunningFreeOS" in nsi

# Unquoted RMDir /r $INSTDIR splits "C:\Program Files\FreeOS".
assert "RMDir /r $INSTDIR" not in nsi
assert "RMDir /r $INSTDIR" not in nsh
assert 'RMDir /r "$INSTDIR"' in nsh
assert 'SetOutPath "$TEMP"' in nsh


def test_uninstall_does_not_wipe_profile_homes() -> None:
nsh = NSH.read_text(encoding="utf-8")
assert r'StrCmp $INSTDIR "$PROFILE\.freeos" wailsWipeSkip' in nsh
assert r'StrCmp $INSTDIR "$PROFILE\.octop" wailsWipeSkip' in nsh
assert r"$PROFILE\.freeos" in nsh
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"))


def test_uninstall_removes_shortcuts_and_program_cache() -> None:
uninstall = _uninstall_section(NSI.read_text(encoding="utf-8"))
assert 'Delete "$SMPROGRAMS\\${INFO_PRODUCTNAME}.lnk"' in uninstall
assert 'Delete "$DESKTOP\\${INFO_PRODUCTNAME}.lnk"' in uninstall
assert 'Delete "$SMSTARTUP\\${INFO_PRODUCTNAME}.lnk"' in uninstall
assert r'RMDir /r "$AppData\${PRODUCT_EXECUTABLE}"' in uninstall
assert "!insertmacro wails.deleteUninstaller" in uninstall


def test_finish_page_run_defaults_checked() -> None:
nsi = NSI.read_text(encoding="utf-8")
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
assert "LangString FINISH_RUN ${LANG_SIMPCHINESE}" in nsi
assert "运行 FreeOS" in nsi
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


def test_desktop_readme_documents_uninstall_keep_vs_remove() -> None:
text = DESKTOP_README.read_text(encoding="utf-8")
assert "## Windows uninstall" in text
assert "Program Files\\FreeOS" in text
assert "%USERPROFILE%\\.freeos" in text
assert "FREEOS_HOME" in text
assert "User Data" in text
assert "userdata" in text
assert "asks" in text
assert "Cancel" in text
assert "Confirm" in text
assert "## Windows install finish" in text
assert "运行 FreeOS" in text
Loading