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 @@ -8,6 +8,7 @@

### 修复

- Windows 安装期 `start-sidecar.ps1` 不再用 PowerShell 5.1 默认的 `UseShellExecute=true` 拉起 Node(那样不会继承 `$env:CORS_ORIGIN`,生产环境 `parseOrigins` 立刻抛错,进程退出,3780 无监听,预配空等 90s 后才以退出码 12 失败)。改为 `UseShellExecute=false` 并显式写入 `ProcessStartInfo.EnvironmentVariables`,把 Node stdout/stderr 记入 `%LOCALAPPDATA%\\FreeOS\\openxyos\\start.log`;进程在 livez 前退出则预配以退出码 10 失败并附上日志摘录。
- Windows 安装把 openXYOS 预配改成 **Setup 子进程**(随包装的 `provision-openxyos.ps1`,NSIS `nsExec` 等待退出码)。子进程用 `tar.exe` 解压到 `%LOCALAPPDATA%\\FreeOS\\openxyos`、整理嵌套/扁平布局、以中完整性启动 FE/BE、通过 `livez` 后才写 `.install-ready`。失败按真实原因提示(解压 / Node / 启动 / 健康检查),不再默认归咎 3780 端口,也不再软跳过 livez。不注册 HKCU Run / 登录计划任务;之后由 FreeOS 启动时带上本机 openXYOS。组织页直接嵌入 `http://127.0.0.1:3780`,不再出现「启动边车」。
- 安装期不再在 livez 探测后 `taskkill` 边车:Setup 把 `start-sidecar.ps1` 写到 `%LOCALAPPDATA%\\FreeOS\\openxyos`,用当前用户(IShellDispatch2 / HKCU Run / 登录计划任务)常驻拉起 FE+BE,确认 `http://127.0.0.1:3780/api/health/livez` 后保持运行。组织页在 `.install-ready` 时自动嵌入本机 openXYOS,不再把「启动边车」当主按钮;仅自动启动失败才显示「重试启动」和中文原因。
- 「下载最新 openXYOS 源码」端到端 UTF-8:Wails 文件夹选择器以 UTF-8 Base64 回传路径(不再把 GBK/ACP 当 UTF-8);落盘前修复 CP1252/Latin-1 误读的目标路径;解压按 zip UTF-8 标志或 GBK 还原中文目录名,避免乱码。
Expand Down
7 changes: 5 additions & 2 deletions desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ Same precedence as the FreeOS CLI/server:
shipped **provisioner subprocess** (`provision-openxyos.ps1`) that expands
`openxyos-runtime.zip` with `tar.exe` into that live workdir (and keeps
`$INSTDIR\openxyos` / `$INSTDIR\openxyos-runtime` as backups), starts FE/BE
at medium integrity, and writes `.install-ready` only after
`http://127.0.0.1:3780/api/health/livez` is healthy. A failed subprocess is
at medium integrity (Node inherits `CORS_ORIGIN` / `NODE_ENV`; stdout/stderr
go to `start.log`), and writes `.install-ready` only after
`http://127.0.0.1:3780/api/health/livez` is healthy. If Node dies before
livez, the provisioner exits 10 with a `start.log` excerpt instead of
waiting out a livez timeout. A failed subprocess is
a failed install step (retry the provisioner). There is no Windows logon
autostart: when FreeOS starts later, it brings local openXYOS up with it.
Organization embeds `http://127.0.0.1:3780` directly.
Expand Down
8 changes: 4 additions & 4 deletions desktop/src/build/windows/nsis/project.nsi
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ LangString OPENXYOS_FAIL_NODE ${LANG_SIMPCHINESE} "解压后缺少 bundled Node
LangString OPENXYOS_FAIL_NODE ${LANG_ENGLISH} "Bundled Node (node\\node.exe) is missing after extract. The package is incomplete."
LangString OPENXYOS_FAIL_FE ${LANG_SIMPCHINESE} "解压后缺少前端(dist\\index.html)。安装包不完整。"
LangString OPENXYOS_FAIL_FE ${LANG_ENGLISH} "Frontend (dist\\index.html) is missing after extract. The package is incomplete."
LangString OPENXYOS_FAIL_START ${LANG_SIMPCHINESE} "未能以当前用户启动 openXYOS(Node 未拉起)。请查看 %LOCALAPPDATA%\\FreeOS\\openxyos\\provision.log。"
LangString OPENXYOS_FAIL_START ${LANG_ENGLISH} "Could not start openXYOS as the current user (Node did not launch). See %LOCALAPPDATA%\\FreeOS\\openxyos\\provision.log."
LangString OPENXYOS_FAIL_START ${LANG_SIMPCHINESE} "未能以当前用户启动 openXYOS,或 Node 启动后立即退出。请查看 %LOCALAPPDATA%\\FreeOS\\openxyos\\start.log 与 provision.log。"
LangString OPENXYOS_FAIL_START ${LANG_ENGLISH} "Could not start openXYOS as the current user, or Node exited immediately. See %LOCALAPPDATA%\\FreeOS\\openxyos\\start.log and provision.log."
LangString OPENXYOS_FAIL_LIVEZ ${LANG_SIMPCHINESE} "openXYOS 已尝试启动,但健康检查未通过。请查看 %LOCALAPPDATA%\\FreeOS\\openxyos\\provision.log 中的启动记录,而不是先假设 3780 端口被占用。"
LangString OPENXYOS_FAIL_LIVEZ ${LANG_ENGLISH} "openXYOS was started but the health check did not pass. See %LOCALAPPDATA%\\FreeOS\\openxyos\\provision.log for the start record; do not assume port 3780 is the cause."
LangString OPENXYOS_FAIL_MARKER ${LANG_SIMPCHINESE} "健康检查已通过,但未能写入 .install-ready。"
Expand All @@ -86,8 +86,8 @@ LangString OPENXYOS_FAIL_UNKNOWN ${LANG_SIMPCHINESE} "openXYOS 预配子进程
LangString OPENXYOS_FAIL_UNKNOWN ${LANG_ENGLISH} "The openXYOS provisioner subprocess failed. See provision.log for the extract / start / health-check step."
LangString OPENXYOS_FAIL_CODE ${LANG_SIMPCHINESE} "退出码 "
LangString OPENXYOS_FAIL_CODE ${LANG_ENGLISH} "Exit code "
LangString OPENXYOS_FAIL_LOG ${LANG_SIMPCHINESE} "详细日志:%LOCALAPPDATA%\\FreeOS\\openxyos\\provision.log"
LangString OPENXYOS_FAIL_LOG ${LANG_ENGLISH} "Log: %LOCALAPPDATA%\\FreeOS\\openxyos\\provision.log"
LangString OPENXYOS_FAIL_LOG ${LANG_SIMPCHINESE} "详细日志:%LOCALAPPDATA%\\FreeOS\\openxyos\\start.log 与 provision.log"
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?"

Expand Down
75 changes: 73 additions & 2 deletions desktop/src/build/windows/nsis/provision-openxyos.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
# 5 tar.exe missing
# 6 node\node.exe missing after extract/heal
# 7 dist\index.html missing after extract/heal
# 10 could not start FE/BE (unelevated launch failed)
# 10 could not start FE/BE, or Node died before livez (see start.log)
# 12 livez timeout after start (+ one automatic start retry)
# 13 livez ok but .install-ready could not be written

Expand Down Expand Up @@ -99,10 +99,78 @@ function Test-OpenXYOSLivez {
}
}

function Get-StartLogExcerpt {
$paths = @(
(Join-Path $LiveDir 'start.log'),
(Join-Path $LiveDir 'start.err.log'),
(Join-Path $LiveDir 'start.out.log')
)
$lines = New-Object System.Collections.Generic.List[string]
foreach ($p in $paths) {
if (Test-Path -LiteralPath $p) {
Get-Content -LiteralPath $p -Tail 40 -ErrorAction SilentlyContinue | ForEach-Object {
$lines.Add($_)
}
}
}
if ($lines.Count -eq 0) { return '' }
return ($lines | Select-Object -Last 40) -join "`n"
}

function Write-StartLogExcerpt {
$excerpt = Get-StartLogExcerpt
if (-not $excerpt) {
Write-ProvLog 'start.log empty or missing (Node produced no stdout/stderr)'
return
}
Write-ProvLog '--- start.log excerpt ---'
foreach ($line in ($excerpt -split "`n")) {
Write-ProvLog $line
}
Write-ProvLog '--- end start.log ---'
}

function Test-OpenXYOSPortListen {
try {
$client = New-Object System.Net.Sockets.TcpClient
$iar = $client.BeginConnect('127.0.0.1', 3780, $null, $null)
$ok = $iar.AsyncWaitHandle.WaitOne(300, $false)
if (-not $ok) {
$client.Close()
return $false
}
$client.EndConnect($iar)
$client.Close()
return $true
} catch {
return $false
}
}

function Test-OpenXYOSNodeAlive {
$pidFile = Join-Path $LiveDir 'start.pid'
if (Test-Path -LiteralPath $pidFile) {
$raw = (Get-Content -LiteralPath $pidFile -Raw -ErrorAction SilentlyContinue)
$nid = 0
if ($null -ne $raw -and [int]::TryParse($raw.Trim(), [ref]$nid) -and $nid -gt 0) {
$p = Get-Process -Id $nid -ErrorAction SilentlyContinue
if ($p -and -not $p.HasExited) { return $true }
}
}
$nodes = Get-Process -Name node -ErrorAction SilentlyContinue
if ($nodes) { return $true }
return $false
}

function Wait-OpenXYOSLivez {
param([int]$Seconds)
for ($i = 0; $i -lt $Seconds; $i++) {
if (Test-OpenXYOSLivez) { return $true }
if ($i -ge 8 -and -not (Test-OpenXYOSLivez) -and -not (Test-OpenXYOSPortListen) -and -not (Test-OpenXYOSNodeAlive)) {
Write-ProvLog 'Node is not running and 3780 is not listening during livez wait'
Write-StartLogExcerpt
exit 10
}
Start-Sleep -Seconds 1
}
return $false
Expand Down Expand Up @@ -273,6 +341,7 @@ if (Test-OpenXYOSLivez) {

if (-not (Start-OpenXYOSUnelevated $LiveDir)) {
Write-ProvLog 'failed to launch FE/BE at medium integrity'
Write-StartLogExcerpt
exit 10
}

Expand All @@ -284,12 +353,14 @@ if (Wait-OpenXYOSLivez $LivezTimeoutSec) {
Write-ProvLog "livez not ready after ${LivezTimeoutSec}s; re-invoking start once"
if (-not (Start-OpenXYOSUnelevated $LiveDir)) {
Write-ProvLog 'retry start failed'
Write-StartLogExcerpt
exit 10
}
if (Wait-OpenXYOSLivez $RetryTimeoutSec) {
if (Write-InstallReady $LiveDir) { exit 0 }
exit 13
}

Write-ProvLog 'livez timeout after start retry — see this log (not assumed to be port 3780 in use)'
Write-ProvLog 'livez timeout after start retry — see this log and start.log (not assumed to be port 3780 in use)'
Write-StartLogExcerpt
exit 12
109 changes: 108 additions & 1 deletion desktop/src/build/windows/nsis/start-sidecar.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -88,5 +88,112 @@ if (Test-Path -LiteralPath $compiled) {
} else {
$argv = @('--import', 'tsx', 'backend/server.ts')
}
Start-Process -FilePath $node -ArgumentList $argv -WorkingDirectory $app -WindowStyle Hidden

# PS 5.1 Start-Process defaults to UseShellExecute=$true and does NOT pass
# $env:CORS_ORIGIN / NODE_ENV into Node. Production parseOrigins then throws
# and Node exits immediately (nothing listens on 3780).
# Launch with ProcessStartInfo.UseShellExecute = $false and copy env onto
# EnvironmentVariables. Start-Process -FilePath $node with Redirect*/-NoNewWindow
# is the file-log path (those switches also force UseShellExecute=$false).
$startLog = Join-Path $live 'start.log'
$startOut = Join-Path $live 'start.out.log'
$startErr = Join-Path $live 'start.err.log'
$pidFile = Join-Path $live 'start.pid'
@(
'{0} launching node={1}' -f (Get-Date -Format o), $node
"cwd=$app"
"argv=$($argv -join ' ')"
"NODE_ENV=$($env:NODE_ENV)"
"PORT=$($env:PORT)"
"CORS_ORIGIN=$($env:CORS_ORIGIN)"
"DB_DIALECT=$($env:DB_DIALECT)"
"AIR_GAP_MODE=$($env:AIR_GAP_MODE)"
) | Set-Content -LiteralPath $startLog -Encoding UTF8
foreach ($old in @($startOut, $startErr)) {
if (Test-Path -LiteralPath $old) {
Remove-Item -LiteralPath $old -Force -ErrorAction SilentlyContinue
}
}

$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.FileName = $node
$psi.WorkingDirectory = $app
$quoted = foreach ($a in $argv) {
if ($a -match '[\s"]') { '"{0}"' -f ($a -replace '"', '\"') } else { $a }
}
$psi.Arguments = [string]::Join(' ', $quoted)
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
foreach ($name in @(
'NODE_ENV', 'PORT', 'DB_DIALECT', 'DATABASE_PATH', 'AIR_GAP_MODE',
'SEED_DEMO_DATA', 'ALLOW_PUBLIC_REGISTRATION', 'JWT_SECRET', 'COOKIE_SECRET',
'FREEOS_INGEST_TOKEN', 'CORS_ORIGIN', 'FREEOS_HOME', 'OCTOP_HOME',
'FREEOS_ORG_SIDECAR_PORT'
)) {
$val = [Environment]::GetEnvironmentVariable($name, 'Process')
if ($null -ne $val -and $val -ne '') {
$psi.EnvironmentVariables[$name] = $val
}
}

function Merge-NodeLogs {
foreach ($f in @($startOut, $startErr)) {
if (Test-Path -LiteralPath $f) {
Add-Content -LiteralPath $startLog -Value ('--- {0} ---' -f (Split-Path -Leaf $f)) -Encoding UTF8
Get-Content -LiteralPath $f -ErrorAction SilentlyContinue |
Add-Content -LiteralPath $startLog -Encoding UTF8
}
}
}

# Redirect* + -NoNewWindow => UseShellExecute=$false, so $env:* is inherited.
# File redirects keep stdout/stderr after this helper exits.
$proc = $null
$usedPsi = $false
try {
$proc = Start-Process -FilePath $node -ArgumentList $argv -WorkingDirectory $app `
-NoNewWindow `
-RedirectStandardOutput $startOut `
-RedirectStandardError $startErr `
-PassThru
} catch {
Add-Content -LiteralPath $startLog -Value "Start-Process failed: $($_.Exception.Message)" -Encoding UTF8
}

if (-not $proc) {
try {
$proc = [System.Diagnostics.Process]::Start($psi)
$usedPsi = $true
} catch {
Add-Content -LiteralPath $startLog -Value "ProcessStartInfo start failed: $($_.Exception.Message)" -Encoding UTF8
exit 10
}
}

if (-not $proc) {
Add-Content -LiteralPath $startLog -Value 'Node process was not created' -Encoding UTF8
exit 10
}

$proc.Id | Set-Content -LiteralPath $pidFile -Encoding ASCII

# Fast crash (missing CORS_ORIGIN, module, etc.) must not look like a 90s livez timeout.
if ($proc.WaitForExit(5000)) {
Merge-NodeLogs
if ($usedPsi) {
try {
$tailOut = $proc.StandardOutput.ReadToEnd()
$tailErr = $proc.StandardError.ReadToEnd()
if ($tailOut) { Add-Content -LiteralPath $startLog -Value $tailOut -Encoding UTF8 }
if ($tailErr) { Add-Content -LiteralPath $startLog -Value $tailErr -Encoding UTF8 }
} catch {
}
}
Add-Content -LiteralPath $startLog -Value "node exited $($proc.ExitCode) (fail-fast)" -Encoding UTF8
exit 10
}
Merge-NodeLogs
Add-Content -LiteralPath $startLog -Value "node still running pid=$($proc.Id)" -Encoding UTF8
exit 0
1 change: 1 addition & 0 deletions tests/unit/desktop/test_nsis_uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ def test_nsis_runs_openxyos_provisioner_subprocess() -> None:
assert ".install-ready" in provision
assert "OPENXYOS_FAIL_START" in nsh
assert "OPENXYOS_FAIL_LIVEZ" in nsh
assert "start.log" in nsi
assert "LangString OPENXYOS_PROVISION ${LANG_SIMPCHINESE}" in nsi
assert "子进程" in nsi
assert "OPENXYOS_PROBE_WARN" not in nsi
Expand Down
40 changes: 40 additions & 0 deletions tests/unit/desktop/test_openxyos_provisioner.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

from __future__ import annotations

import re
from pathlib import Path
from urllib.parse import urlparse

REPO = Path(__file__).resolve().parents[3]
NSIS = REPO / "desktop" / "src" / "build" / "windows" / "nsis"
Expand Down Expand Up @@ -50,6 +52,44 @@ def test_provisioner_starts_medium_integrity_and_requires_livez() -> None:
assert "Test-IsElevated" in start
assert "refusing High-IL Node" in start
assert "Start-Process -FilePath $node" in start
assert "UseShellExecute = $false" in start
assert "EnvironmentVariables" in start
assert "RedirectStandardError" in start
assert "NoNewWindow" in start
assert "UseNewEnvironment" not in start
assert "start.log" in start
assert "WaitForExit" in start
assert "$env:CORS_ORIGIN" in start
assert "Write-StartLogExcerpt" in text
assert "Test-OpenXYOSNodeAlive" in text
assert "Test-OpenXYOSPortListen" in text
assert "exit 10" in text


def test_start_sidecar_cors_origin_is_parseorigins_safe() -> None:
"""openXYOS parseOrigins requires explicit http(s) origins, no '*'."""
text = START_PS1.read_text(encoding="utf-8")
match = re.search(r"\$env:CORS_ORIGIN\s*=\s*'([^']+)'", text)
assert match, "start-sidecar.ps1 must set $env:CORS_ORIGIN"
origins = [part.strip() for part in match.group(1).split(",") if part.strip()]
assert origins
assert "*" not in origins
for origin in origins:
parsed = urlparse(origin)
assert parsed.scheme in {"http", "https"}, origin
assert parsed.netloc, origin


def test_provisioner_fail_fast_when_node_dies() -> None:
text = PROVISION_PS1.read_text(encoding="utf-8")
assert "start.log excerpt" in text
assert "Node is not running and 3780 is not listening" in text
# Dead Node during livez wait is exit 10, not a 90s exit 12.
dead = text.index("Node is not running and 3780 is not listening")
exit10 = text.index("exit 10", dead)
exit12 = text.index("exit 12")
assert exit10 < exit12
assert "LivezTimeoutSec" in text


def test_provisioner_writes_marker_only_when_healthy() -> None:
Expand Down
Loading