diff --git a/CHANGELOG.md b/CHANGELOG.md index e5c32626..b298b2b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 还原中文目录名,避免乱码。 diff --git a/desktop/README.md b/desktop/README.md index 8af18ef4..aa6e65f7 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -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. diff --git a/desktop/src/build/windows/nsis/project.nsi b/desktop/src/build/windows/nsis/project.nsi index c4ebae51..088ef219 100644 --- a/desktop/src/build/windows/nsis/project.nsi +++ b/desktop/src/build/windows/nsis/project.nsi @@ -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。" @@ -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?" diff --git a/desktop/src/build/windows/nsis/provision-openxyos.ps1 b/desktop/src/build/windows/nsis/provision-openxyos.ps1 index fc2d6032..479c36ca 100644 --- a/desktop/src/build/windows/nsis/provision-openxyos.ps1 +++ b/desktop/src/build/windows/nsis/provision-openxyos.ps1 @@ -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 @@ -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 @@ -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 } @@ -284,6 +353,7 @@ 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) { @@ -291,5 +361,6 @@ if (Wait-OpenXYOSLivez $RetryTimeoutSec) { 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 diff --git a/desktop/src/build/windows/nsis/start-sidecar.ps1 b/desktop/src/build/windows/nsis/start-sidecar.ps1 index 233a77b7..28919442 100644 --- a/desktop/src/build/windows/nsis/start-sidecar.ps1 +++ b/desktop/src/build/windows/nsis/start-sidecar.ps1 @@ -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 diff --git a/tests/unit/desktop/test_nsis_uninstall.py b/tests/unit/desktop/test_nsis_uninstall.py index d93e9a8c..01beae11 100644 --- a/tests/unit/desktop/test_nsis_uninstall.py +++ b/tests/unit/desktop/test_nsis_uninstall.py @@ -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 diff --git a/tests/unit/desktop/test_openxyos_provisioner.py b/tests/unit/desktop/test_openxyos_provisioner.py index 615dba54..d31ddd51 100644 --- a/tests/unit/desktop/test_openxyos_provisioner.py +++ b/tests/unit/desktop/test_openxyos_provisioner.py @@ -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" @@ -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: