diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c9794f..05ba747 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,8 @@ jobs: $scripts = @( "apps/tray/start-tray.ps1", "scripts/start-beauticode-engine.ps1", - "scripts/install-desktop-shortcut.ps1" + "scripts/install-desktop-shortcut.ps1", + "scripts/build-windows-installer.ps1" ) foreach ($script in $scripts) { $tokens = $null diff --git a/.gitignore b/.gitignore index 1bfa5bd..74e4b54 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ coverage/ apps/tray/node_modules/ packages/*/dist/ packages/*/node_modules/ +artifacts/ diff --git a/README.md b/README.md index 17d4f3a..6dde5ec 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,11 @@ beautiCode 可以让你: > 不是逃离工作,而是给漫长的工作过程,留一点属于自己的空间。 +### v0.1 安装包 + +想直接使用 beautiCode,可以下载 [Windows 安装包](https://github.com/starsstreaming/beautiCode/releases/tag/v0.1)。 +安装包自带运行时,不需要另外安装 Node.js 或 npm;使用前请先安装 Codex Desktop。 + --- @@ -152,18 +157,42 @@ Ctrl + Shift + Space ## 使用方式 -### 运行托盘程序 +### Windows 安装包(推荐) + +运行: + +```text +beautiCode-Setup-0.1.0-win-x64.exe +``` + +安装包自带 Node.js 运行时。使用者不需要安装 Node.js、npm,也不需要保留 +项目源码。安装完成后启动 beautiCode,Windows 右下角会出现托盘图标; +如果 Codex Desktop 尚未启动,引擎会尝试启动并连接它。 -目前版本需要安装 Node.js 22 或更高版本。 +当前安装包尚未进行商业代码签名,Windows 可能显示 SmartScreen 提示。 +用户导入的图片、视频和已保存主题位于 +`%LOCALAPPDATA%\beautiCode`,卸载程序默认保留这些数据。 -在项目目录执行: +### 从源码运行 + +源码开发需要 Node.js 22 或更高版本。在项目目录执行: ```bash npm install npm run tray ``` -启动后,Windows 右下角会出现 beautiCode 托盘图标。 +构建 Windows 安装包: + +```powershell +npm run installer:windows +``` + +输出位于: + +```text +artifacts\windows\installer\ +``` 通过托盘菜单可以: diff --git a/apps/tray/start-tray.ps1 b/apps/tray/start-tray.ps1 index 4e905d0..6d4602b 100644 --- a/apps/tray/start-tray.ps1 +++ b/apps/tray/start-tray.ps1 @@ -2,11 +2,26 @@ param( [int]$Port = 0, [string]$DataRoot = "", + [string]$NodePath = "", [switch]$SkipBuild ) $ErrorActionPreference = "Stop" +$script:trayInstanceMutex = $null +$script:trayInstanceMutexOwned = $false +$createdNew = $false +$script:trayInstanceMutex = [System.Threading.Mutex]::new( + $true, + "Local\beautiCode.Engine.Tray.v1", + [ref]$createdNew +) +if (-not $createdNew) { + $script:trayInstanceMutex.Dispose() + exit 0 +} +$script:trayInstanceMutexOwned = $true + $dpiNativeCode = @' using System; using System.Runtime.InteropServices; @@ -53,9 +68,138 @@ Add-Type -AssemblyName System.Drawing [System.Windows.Forms.Application]::EnableVisualStyles() [System.Windows.Forms.Application]::SetCompatibleTextRenderingDefault($false) +if (-not ("BeautiCodeToggle" -as [type])) { + $toggleCode = @' +using System; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Windows.Forms; + +public sealed class BeautiCodeToggle : Control { + private bool isChecked; + + public BeautiCodeToggle() { + SetStyle( + ControlStyles.AllPaintingInWmPaint | + ControlStyles.OptimizedDoubleBuffer | + ControlStyles.ResizeRedraw | + ControlStyles.UserPaint, + true + ); + Cursor = Cursors.Hand; + TabStop = true; + } + + public bool Checked { + get { return isChecked; } + set { + if (isChecked == value) return; + isChecked = value; + Invalidate(); + } + } + + public Color TrackOnColor { get; set; } + public Color TrackOffColor { get; set; } + public Color BorderOnColor { get; set; } + public Color BorderOffColor { get; set; } + public Color KnobColor { get; set; } + + private static GraphicsPath RoundedRect(RectangleF rect) { + float diameter = rect.Height; + GraphicsPath path = new GraphicsPath(); + path.AddArc(rect.Left, rect.Top, diameter, diameter, 90, 180); + path.AddArc(rect.Right - diameter, rect.Top, diameter, diameter, 270, 180); + path.CloseFigure(); + return path; + } + + protected override void OnPaint(PaintEventArgs e) { + base.OnPaint(e); + e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; + e.Graphics.PixelOffsetMode = PixelOffsetMode.HighQuality; + + RectangleF track = new RectangleF( + 0.75f, + 0.75f, + Math.Max(1f, ClientSize.Width - 1.5f), + Math.Max(1f, ClientSize.Height - 1.5f) + ); + Color trackColor = isChecked ? TrackOnColor : TrackOffColor; + Color borderColor = isChecked ? BorderOnColor : BorderOffColor; + + using (GraphicsPath path = RoundedRect(track)) + using (SolidBrush trackBrush = new SolidBrush(trackColor)) + using (Pen borderPen = new Pen(borderColor, 1f)) { + e.Graphics.FillPath(trackBrush, path); + e.Graphics.DrawPath(borderPen, path); + } + + float inset = Math.Max(3f, track.Height * 0.14f); + float knobSize = Math.Max(8f, track.Height - (inset * 2f)); + float knobX = isChecked + ? track.Right - inset - knobSize + : track.Left + inset; + RectangleF knob = new RectangleF( + knobX, + track.Top + inset, + knobSize, + knobSize + ); + RectangleF shadow = knob; + shadow.Y += Math.Max(1f, track.Height * 0.04f); + + using (SolidBrush shadowBrush = new SolidBrush(Color.FromArgb(58, 0, 0, 0))) + using (SolidBrush knobBrush = new SolidBrush(KnobColor)) { + e.Graphics.FillEllipse(shadowBrush, shadow); + e.Graphics.FillEllipse(knobBrush, knob); + } + + if (Focused && ShowFocusCues) { + Rectangle focus = ClientRectangle; + focus.Inflate(-2, -2); + ControlPaint.DrawFocusRectangle(e.Graphics, focus); + } + } + + protected override void OnKeyDown(KeyEventArgs e) { + if (e.KeyCode == Keys.Space || e.KeyCode == Keys.Enter) { + OnClick(EventArgs.Empty); + e.Handled = true; + e.SuppressKeyPress = true; + return; + } + base.OnKeyDown(e); + } +} +'@ + Add-Type -TypeDefinition $toggleCode -ReferencedAssemblies @( + "System.Drawing", + "System.Windows.Forms" + ) -ErrorAction Stop +} + $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path $HostScript = Join-Path $PSScriptRoot "session-host.mjs" -$Node = (Get-Command node -ErrorAction Stop).Source +$ReleaseManifest = Join-Path $RepoRoot "release-manifest.json" +$BundledNode = Join-Path $RepoRoot "runtime\node.exe" +$coreDist = Join-Path $RepoRoot "packages\core\dist\index.js" +$adapterDist = Join-Path $RepoRoot "packages\adapter-codex\dist\index.js" + +if ($NodePath) { + if (-not (Test-Path -LiteralPath $NodePath -PathType Leaf)) { + throw ("Node executable not found: {0}" -f $NodePath) + } + $Node = (Resolve-Path -LiteralPath $NodePath).Path +} elseif (Test-Path -LiteralPath $BundledNode -PathType Leaf) { + $Node = $BundledNode +} else { + $Node = (Get-Command node -ErrorAction Stop).Source +} + +if (Test-Path -LiteralPath $ReleaseManifest -PathType Leaf) { + $SkipBuild = $true +} # Known ChatGPT / Codex Desktop AppX application user model id (Windows). $script:CodexAppUserModelId = "OpenAI.Codex_2p2nqsd0c76g0!App" @@ -67,8 +211,6 @@ if (-not (Test-Path -LiteralPath $HostScript)) { if (-not $SkipBuild) { # Only build when dist is missing — full npm build on every tray start was a # multi-second tax. Development rebuilds: npm run build / npm run tray. - $coreDist = Join-Path $RepoRoot "packages\core\dist\index.js" - $adapterDist = Join-Path $RepoRoot "packages\adapter-codex\dist\index.js" $needBuild = -not ((Test-Path -LiteralPath $coreDist) -and (Test-Path -LiteralPath $adapterDist)) if (-not $needBuild) { $distCutoff = @( @@ -112,6 +254,12 @@ if (-not $SkipBuild) { } } +foreach ($requiredRuntimeFile in @($HostScript, $coreDist, $adapterDist)) { + if (-not (Test-Path -LiteralPath $requiredRuntimeFile -PathType Leaf)) { + throw ("Missing beautiCode runtime file: {0}" -f $requiredRuntimeFile) + } +} + # Cryptographically strong token for the local control plane. $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() try { @@ -1445,14 +1593,19 @@ function Add-BcSwitchRow { $row.Cursor = [System.Windows.Forms.Cursors]::Hand $titleLabel = New-BcUiLabel -Text $Title -X 12 -Y 7 -Width 205 -Height 28 ` -Font $script:bcBodyFont -Color $script:bcUiColors.Text - $stateLabel = New-BcUiLabel -Text "" -X 220 -Y 7 -Width 104 -Height 28 ` + $stateLabel = New-BcUiLabel -Text "" -X 218 -Y 7 -Width 96 -Height 28 ` -Font $script:bcMetaFont -Color $script:bcUiColors.Muted ` -Align ([System.Drawing.ContentAlignment]::MiddleRight) - $toggle = New-BcUiButton -Text ([string][char]0x25CF) -X 334 -Y 11 -Width 38 -Height 20 ` - -Font $script:bcToggleFont -BackColor $script:bcUiColors.ToggleOff ` - -ForeColor $script:bcUiColors.ToggleKnob -BorderColor $script:bcUiColors.ToggleOff ` - -Align ([System.Drawing.ContentAlignment]::MiddleLeft) - $toggle.FlatAppearance.BorderSize = 0 + $toggle = New-Object BeautiCodeToggle + $toggle.Location = New-Object System.Drawing.Point(326, 9) + $toggle.Size = New-Object System.Drawing.Size(48, 24) + $toggle.BackColor = $script:bcUiColors.Panel + $toggle.TrackOnColor = $script:bcUiColors.JadeDeep + $toggle.TrackOffColor = $script:bcUiColors.ToggleOff + $toggle.BorderOnColor = $script:bcUiColors.Jade + $toggle.BorderOffColor = $script:bcUiColors.ToggleOffBorder + $toggle.KnobColor = $script:bcUiColors.ToggleKnob + $toggle.AccessibleName = $Title Register-BcPanelAction -Control $row -ActionName $ActionName Register-BcPanelAction -Control $titleLabel -ActionName $ActionName Register-BcPanelAction -Control $stateLabel -ActionName $ActionName @@ -1461,7 +1614,6 @@ function Add-BcSwitchRow { [void]$row.Controls.Add($stateLabel) [void]$row.Controls.Add($toggle) [void]$Parent.Controls.Add($row) - Set-BcRoundedRegion -Control $toggle -Radius 10 return @{ Panel = $row State = $stateLabel @@ -1623,21 +1775,13 @@ function Update-BcTrayPanel { $fishOn = [bool]$script:fishMode $script:bcFishSwitch.State.Text = if ($fishOn) { $L.EnabledLabel } else { $L.DisabledLabel } - $script:bcFishSwitch.Toggle.BackColor = if ($fishOn) { $script:bcUiColors.JadeDeep } else { $script:bcUiColors.ToggleOff } - $script:bcFishSwitch.Toggle.TextAlign = if ($fishOn) { - [System.Drawing.ContentAlignment]::MiddleRight - } else { - [System.Drawing.ContentAlignment]::MiddleLeft - } + $script:bcFishSwitch.State.ForeColor = if ($fishOn) { $script:bcUiColors.Jade } else { $script:bcUiColors.Muted } + $script:bcFishSwitch.Toggle.Checked = $fishOn $soundOn = -not [bool]$script:videoMuted $script:bcSoundSwitch.State.Text = if ($soundOn) { $L.EnabledLabel } else { $L.DisabledLabel } - $script:bcSoundSwitch.Toggle.BackColor = if ($soundOn) { $script:bcUiColors.JadeDeep } else { $script:bcUiColors.ToggleOff } - $script:bcSoundSwitch.Toggle.TextAlign = if ($soundOn) { - [System.Drawing.ContentAlignment]::MiddleRight - } else { - [System.Drawing.ContentAlignment]::MiddleLeft - } + $script:bcSoundSwitch.State.ForeColor = if ($soundOn) { $script:bcUiColors.Jade } else { $script:bcUiColors.Muted } + $script:bcSoundSwitch.Toggle.Checked = $soundOn $savedItem = Get-BcMenuItem -Name "themes" $themeText = $L.NoSavedThemes @@ -1725,6 +1869,41 @@ public static class BeautiCodeUiNative { '@ Add-Type -TypeDefinition $nativeCode -ErrorAction Stop } + + $assetPath = [System.IO.Path]::GetFullPath( + (Join-Path $PSScriptRoot "..\..\assets\beauticode-icon-borderless.png") + ) + if (Test-Path -LiteralPath $assetPath -PathType Leaf) { + $source = $null + $assetBitmap = $null + $assetGraphics = $null + try { + $source = [System.Drawing.Image]::FromFile($assetPath) + $assetBitmap = New-Object System.Drawing.Bitmap(32, 32) + $assetGraphics = [System.Drawing.Graphics]::FromImage($assetBitmap) + $assetGraphics.CompositingMode = [System.Drawing.Drawing2D.CompositingMode]::SourceCopy + $assetGraphics.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality + $assetGraphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $assetGraphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality + $assetGraphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality + $assetGraphics.Clear([System.Drawing.Color]::Transparent) + $assetGraphics.DrawImage($source, 0, 0, 32, 32) + + $handle = $assetBitmap.GetHicon() + try { + return ([System.Drawing.Icon]::FromHandle($handle).Clone()) + } finally { + [void][BeautiCodeUiNative]::DestroyIcon($handle) + } + } catch { + # Keep the generated glyph below as a fail-soft fallback. + } finally { + if ($null -ne $assetGraphics) { $assetGraphics.Dispose() } + if ($null -ne $assetBitmap) { $assetBitmap.Dispose() } + if ($null -ne $source) { $source.Dispose() } + } + } + $bitmap = New-Object System.Drawing.Bitmap(32, 32) $graphics = [System.Drawing.Graphics]::FromImage($bitmap) $brush = New-Object System.Drawing.SolidBrush($script:bcUiColors.Panel) @@ -1757,6 +1936,41 @@ public static class BeautiCodeUiNative { } } +function New-BcHeaderMark { + $assetPath = [System.IO.Path]::GetFullPath( + (Join-Path $PSScriptRoot "..\..\assets\beauticode-icon-borderless.png") + ) + if (Test-Path -LiteralPath $assetPath -PathType Leaf) { + $source = $null + $iconImage = $null + try { + $source = [System.Drawing.Image]::FromFile($assetPath) + $iconImage = $source.Clone() + $mark = New-Object System.Windows.Forms.PictureBox + $mark.Location = New-Object System.Drawing.Point(18, 16) + $mark.Size = New-Object System.Drawing.Size(39, 39) + $mark.BackColor = [System.Drawing.Color]::Transparent + $mark.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::Zoom + $mark.Image = $iconImage + $script:bcHeaderIconImage = $iconImage + $iconImage = $null + return $mark + } catch { + # Keep the generated glyph below as a fail-soft fallback. + } finally { + if ($null -ne $iconImage) { $iconImage.Dispose() } + if ($null -ne $source) { $source.Dispose() } + } + } + + $mark = New-BcUiLabel -Text (U "7F8E") -X 18 -Y 16 -Width 39 -Height 39 ` + -Font $script:bcDisplayFont -Color $script:bcUiColors.Jade ` + -Align ([System.Drawing.ContentAlignment]::MiddleCenter) + $mark.BorderStyle = [System.Windows.Forms.BorderStyle]::FixedSingle + Set-BcRoundedRegion -Control $mark -Radius 19 + return $mark +} + function Initialize-BcTrayPanel { $systemDpi = [Math]::Max(96, [BeautiCodeDpiNative]::SystemDpi()) $script:bcGeometryScale = 0.86 * ($systemDpi / 96.0) @@ -1771,8 +1985,9 @@ function Initialize-BcTrayPanel { Jade = Get-BcUiColor "#86AA91" JadeDeep = Get-BcUiColor "#496854" Copper = Get-BcUiColor "#B98265" - ToggleOff = Get-BcUiColor "#555A53" - ToggleKnob = Get-BcUiColor "#D4D7D1" + ToggleOff = Get-BcUiColor "#4F554E" + ToggleOffBorder = Get-BcUiColor "#737A71" + ToggleKnob = Get-BcUiColor "#F1F3EE" } $displayFamily = "KaiTi" @@ -1782,7 +1997,6 @@ function Initialize-BcTrayPanel { $script:bcBodyStrongFont = New-Object System.Drawing.Font("Microsoft YaHei UI", 10, [System.Drawing.FontStyle]::Bold, [System.Drawing.GraphicsUnit]::Point) $script:bcMetaFont = New-Object System.Drawing.Font("Microsoft YaHei UI", 9, [System.Drawing.FontStyle]::Regular, [System.Drawing.GraphicsUnit]::Point) $script:bcSectionFont = New-Object System.Drawing.Font("Microsoft YaHei UI", 8.5, [System.Drawing.FontStyle]::Regular, [System.Drawing.GraphicsUnit]::Point) - $script:bcToggleFont = New-Object System.Drawing.Font("Segoe UI Symbol", 9, [System.Drawing.FontStyle]::Regular, [System.Drawing.GraphicsUnit]::Point) $script:bcIconFont = New-Object System.Drawing.Font($displayFamily, 15, [System.Drawing.FontStyle]::Regular, [System.Drawing.GraphicsUnit]::Pixel) $form = New-Object System.Windows.Forms.Form @@ -1809,12 +2023,8 @@ function Initialize-BcTrayPanel { $header.BackColor = $script:bcUiColors.Panel [void]$root.Controls.Add($header) - $mark = New-BcUiLabel -Text (U "7F8E") -X 18 -Y 16 -Width 39 -Height 39 ` - -Font $script:bcDisplayFont -Color $script:bcUiColors.Jade ` - -Align ([System.Drawing.ContentAlignment]::MiddleCenter) - $mark.BorderStyle = [System.Windows.Forms.BorderStyle]::FixedSingle + $mark = New-BcHeaderMark [void]$header.Controls.Add($mark) - Set-BcRoundedRegion -Control $mark -Radius 19 $brand = New-BcUiLabel -Text $L.AppName -X 69 -Y 12 -Width 205 -Height 31 ` -Font $script:bcBrandFont -Color $script:bcUiColors.Text @@ -1941,8 +2151,6 @@ function Initialize-BcTrayPanel { @{ Control = $reapply; Radius = 9 }, @{ Control = $script:bcImageCard.Panel; Radius = 10 }, @{ Control = $script:bcVideoCard.Panel; Radius = 10 }, - @{ Control = $script:bcFishSwitch.Toggle; Radius = 10 }, - @{ Control = $script:bcSoundSwitch.Toggle; Radius = 10 }, @{ Control = $themeCard; Radius = 10 } ) Set-BcIntegerLayoutTree -Control $form @@ -2067,6 +2275,13 @@ try { } catch { # ignore } + try { + if ($null -ne $script:bcHeaderIconImage) { + $script:bcHeaderIconImage.Dispose() + } + } catch { + # ignore + } foreach ($fontName in @( "bcDisplayFont", "bcBrandFont", @@ -2074,7 +2289,6 @@ try { "bcBodyStrongFont", "bcMetaFont", "bcSectionFont", - "bcToggleFont", "bcIconFont" )) { try { @@ -2090,4 +2304,12 @@ try { if ($errEvent) { Unregister-Event -SourceIdentifier $errEvent.Name -ErrorAction SilentlyContinue } + if ($null -ne $script:trayInstanceMutex) { + if ($script:trayInstanceMutexOwned) { + try { $script:trayInstanceMutex.ReleaseMutex() } catch { } + $script:trayInstanceMutexOwned = $false + } + try { $script:trayInstanceMutex.Dispose() } catch { } + $script:trayInstanceMutex = $null + } } diff --git a/docs/windows-installer.md b/docs/windows-installer.md new file mode 100644 index 0000000..45091e9 --- /dev/null +++ b/docs/windows-installer.md @@ -0,0 +1,60 @@ +# Windows installer + +## User experience + +`beautiCode-Setup--win-x64.exe` installs per user into: + +```text +%LOCALAPPDATA%\Programs\beautiCode +``` + +The package includes the compiled beautiCode runtime and a pinned Node.js x64 +runtime. The target machine does not need Node.js, npm, TypeScript, or a source +checkout. Codex Desktop remains a separate prerequisite. + +The installer creates a Start menu shortcut. Desktop and login-start shortcuts +are optional installer tasks. Uninstall removes program files and shortcuts but +preserves `%LOCALAPPDATA%\beautiCode`, which contains user media and themes. +Quit the beautiCode tray before an upgrade or uninstall so the bundled runtime +is not in use. + +## Build + +Prerequisites: + +- Windows PowerShell 5.1 +- Node.js 22 or newer for the build machine +- npm dependencies installed with `npm ci` +- Inno Setup 6 + +Build: + +```powershell +npm run installer:windows +``` + +The build script: + +1. compiles the TypeScript workspaces; +2. creates a minimal runtime staging directory; +3. downloads the pinned official Node.js Windows x64 ZIP; +4. verifies it against the release `SHASUMS256.txt`; +5. includes the Node.js license; +6. compiles the Inno Setup installer; +7. prints the final SHA-256 digest. + +Generated files are under `artifacts\windows\` and are intentionally ignored by +Git. + +## Release boundary + +The current installer is unsigned. Windows may show a SmartScreen warning until +the final EXE is signed with a trusted code-signing certificate. + +The locally installed Inno Setup 6.7.3 compiler identifies itself as +non-commercial-use-only. Review or replace that build-tool license before using +this pipeline for a commercial distribution. + +The installer targets x64-compatible Windows. It does not bundle, patch, or +modify Codex Desktop. Runtime integration still depends on Codex exposing a +healthy loopback CDP endpoint. diff --git a/installer/windows/beauticode.iss b/installer/windows/beauticode.iss new file mode 100644 index 0000000..35ffbeb --- /dev/null +++ b/installer/windows/beauticode.iss @@ -0,0 +1,58 @@ +#define MyAppName "beautiCode" +#define MyAppPublisher "beautiCode" + +#ifndef MyAppVersion + #define MyAppVersion "0.1.0" +#endif + +#ifndef StageDir + #error StageDir must point to the packaged Windows release directory. +#endif + +#ifndef OutputDir + #error OutputDir must point to the installer output directory. +#endif + +[Setup] +AppId={{690C1A5A-97DB-4D28-B9A6-73F47D7D46F7} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +DefaultDirName={localappdata}\Programs\beautiCode +DefaultGroupName=beautiCode +DisableProgramGroupPage=yes +PrivilegesRequired=lowest +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +OutputDir={#OutputDir} +OutputBaseFilename=beautiCode-Setup-{#MyAppVersion}-win-x64 +SetupIconFile={#StageDir}\beauticode.ico +UninstallDisplayIcon={app}\beauticode.ico +Compression=lzma2/ultra64 +SolidCompression=yes +WizardStyle=modern +CloseApplications=yes +RestartApplications=no +AppMutex=Local\beautiCode.Engine.Tray.v1 +VersionInfoVersion={#MyAppVersion}.0 +VersionInfoProductName={#MyAppName} +VersionInfoDescription=beautiCode Windows installer +VersionInfoCompany={#MyAppPublisher} + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加选项:"; Flags: unchecked +Name: "autostart"; Description: "登录 Windows 后自动启动"; GroupDescription: "附加选项:"; Flags: unchecked + +[Files] +Source: "{#StageDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{autoprograms}\beautiCode"; Filename: "{sys}\WindowsPowerShell\v1.0\powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""{app}\scripts\start-beauticode-engine.ps1"""; WorkingDir: "{app}"; IconFilename: "{app}\beauticode.ico" +Name: "{autodesktop}\beautiCode"; Filename: "{sys}\WindowsPowerShell\v1.0\powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""{app}\scripts\start-beauticode-engine.ps1"""; WorkingDir: "{app}"; IconFilename: "{app}\beauticode.ico"; Tasks: desktopicon +Name: "{userstartup}\beautiCode"; Filename: "{sys}\WindowsPowerShell\v1.0\powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""{app}\scripts\start-beauticode-engine.ps1"" -NoLaunchCodex"; WorkingDir: "{app}"; IconFilename: "{app}\beauticode.ico"; Tasks: autostart + +[Run] +Filename: "{sys}\WindowsPowerShell\v1.0\powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""{app}\scripts\start-beauticode-engine.ps1"""; WorkingDir: "{app}"; Description: "启动 beautiCode"; Flags: postinstall nowait skipifsilent diff --git a/package.json b/package.json index bd56e1d..0cf648a 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "discover": "node scripts/beauticode.mjs discover", "bc": "node scripts/beauticode.mjs", "smoke:live": "npm run build && node scripts/live-smoke.mjs", - "tray": "npm run build && powershell -NoProfile -ExecutionPolicy Bypass -File apps/tray/start-tray.ps1 -SkipBuild" + "tray": "npm run build && powershell -NoProfile -ExecutionPolicy Bypass -File apps/tray/start-tray.ps1 -SkipBuild", + "installer:windows": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build-windows-installer.ps1" }, "engines": { "node": ">=22" diff --git a/packages/adapter-codex/src/session.ts b/packages/adapter-codex/src/session.ts index 2482d07..4fa09cc 100644 --- a/packages/adapter-codex/src/session.ts +++ b/packages/adapter-codex/src/session.ts @@ -210,13 +210,34 @@ export class BeautiSession { this.host = this.createHost(this.port); this.detachedVideoKey = ""; } - if (this.host.activeSessionCount === 0) { - await this.host.connect(); - } else { - await this.host.reconcileSessions(); + const connectCurrentHost = async () => { + if (!this.host) throw new CdpError("Host is not connected"); if (this.host.activeSessionCount === 0) { await this.host.connect(); + } else { + await this.host.reconcileSessions(); + if (this.host.activeSessionCount === 0) { + await this.host.connect(); + } } + }; + + try { + await connectCurrentHost(); + } catch (err) { + if (!(err instanceof CdpIdentityMismatchError) || this.port == null) { + throw err; + } + + // A normal Codex restart keeps the loopback port but replaces the + // Chromium browser identity. Drop every stale target and bind once to + // the freshly probed browser so the triggering user action can finish. + this.onStatus?.(`Codex CDP restarted on :${this.port}; reconnecting…`); + this.host?.close(); + this.host = this.createHost(this.port); + this.detachedVideoKey = ""; + this.lastPublishSessionKey = ""; + await connectCurrentHost(); } if (this.closed) { this.host.close(); diff --git a/packages/adapter-codex/test/adapter.test.js b/packages/adapter-codex/test/adapter.test.js index ac63230..b11210e 100644 --- a/packages/adapter-codex/test/adapter.test.js +++ b/packages/adapter-codex/test/adapter.test.js @@ -591,6 +591,43 @@ test("BeautiSession applies image against mock CDP", async () => { } }); +test("BeautiSession reapply reconnects after CDP browser identity changes", async () => { + const mock = await startMockCdp(); + const root = await fs.mkdtemp(path.join(os.tmpdir(), "bc-restart-")); + const png = Buffer.from( + "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c63000100000500010d0a2db40000000049454e44ae426082", + "hex", + ); + const imagePath = path.join(root, "a.png"); + await fs.writeFile(imagePath, png); + const errors = []; + const session = new BeautiSession({ + port: mock.port, + dataRoot: path.join(root, "data"), + verifyDeadlineMs: 5_000, + pollMs: 10_000, + autoDiscover: false, + deferHostConnect: false, + onError: (err) => errors.push(err), + }); + try { + await session.start(); + const applied = await session.apply({ type: "image", imagePath }); + assert.equal(applied.ok, true, applied.ok ? "" : applied.error); + + mock.rotateBrowserIdentity("test-browser-after-restart"); + const reapplied = await session.reapply(); + + assert.equal(reapplied.ok, true, reapplied.ok ? "" : reapplied.error); + assert.equal(session.isHostReady, true); + assert.equal(errors.length, 0); + } finally { + await session.stop(); + await mock.close(); + await fs.rm(root, { recursive: true, force: true }); + } +}); + test("BeautiSession stop drains a deferred startup and releases its lock", async () => { const mock = await startMockCdp(); const root = await fs.mkdtemp(path.join(os.tmpdir(), "bc-stop-")); diff --git a/packages/adapter-codex/test/mock-cdp.js b/packages/adapter-codex/test/mock-cdp.js index 6207cab..7a75b69 100644 --- a/packages/adapter-codex/test/mock-cdp.js +++ b/packages/adapter-codex/test/mock-cdp.js @@ -8,7 +8,7 @@ import { WebSocketServer } from "ws"; */ export async function startMockCdp(opts = {}) { - const browserId = "test-browser"; + let browserId = "test-browser"; const pageId = "page-test"; const state = { body: true, @@ -344,9 +344,15 @@ export async function startMockCdp(opts = {}) { return { port, - browserId, + get browserId() { + return browserId; + }, pageId, state, + rotateBrowserIdentity(nextId = `test-browser-${Date.now()}`) { + browserId = nextId; + return browserId; + }, setEvaluateImpl(fn) { evaluateImpl = fn; }, diff --git a/scripts/build-windows-installer.ps1 b/scripts/build-windows-installer.ps1 new file mode 100644 index 0000000..d6ae9b5 --- /dev/null +++ b/scripts/build-windows-installer.ps1 @@ -0,0 +1,295 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Build a self-contained beautiCode Windows installer. + +.DESCRIPTION + Builds TypeScript, stages only runtime files, downloads and verifies the + official Node.js Windows x64 runtime, then compiles an Inno Setup installer. +#> +[CmdletBinding()] +param( + [ValidatePattern("^\d+\.\d+\.\d+$")] + [string]$NodeVersion = "24.18.0", + [string]$InnoCompiler = "" +) + +$ErrorActionPreference = "Stop" +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$ArtifactsRoot = Join-Path $RepoRoot "artifacts\windows" +$StageRoot = Join-Path $ArtifactsRoot "stage" +$CacheRoot = Join-Path $ArtifactsRoot "cache" +$OutputRoot = Join-Path $ArtifactsRoot "installer" +$InstallerScript = Join-Path $RepoRoot "installer\windows\beauticode.iss" +$PinnedNodeVersion = "24.18.0" +$PinnedNodeArchiveSha256 = "0ae68406b42d7725661da979b1403ec9926da205c6770827f33aac9d8f26e821" + +if ($env:OS -ne "Windows_NT") { + throw "The Windows installer must be built on Windows." +} + +function Assert-WorkspaceChild([string]$Path) { + $root = [System.IO.Path]::GetFullPath($RepoRoot).TrimEnd("\") + "\" + $target = [System.IO.Path]::GetFullPath($Path) + if (-not $target.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) { + throw ("Refusing to modify a path outside the repository: {0}" -f $target) + } +} + +function Reset-Directory([string]$Path) { + Assert-WorkspaceChild $Path + if (Test-Path -LiteralPath $Path) { + Remove-Item -LiteralPath $Path -Recurse -Force + } + New-Item -ItemType Directory -Path $Path -Force | Out-Null +} + +function Copy-RuntimeDirectory([string]$Source, [string]$Destination) { + if (-not (Test-Path -LiteralPath $Source -PathType Container)) { + throw ("Missing runtime directory: {0}" -f $Source) + } + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + Copy-Item -Path (Join-Path $Source "*") -Destination $Destination -Recurse -Force +} + +function Invoke-VerifiedDownload( + [string]$Url, + [string]$Destination +) { + if (Test-Path -LiteralPath $Destination -PathType Leaf) { + return + } + $curl = (Get-Command curl.exe -ErrorAction Stop).Source + & $curl -L --fail --retry 5 --retry-delay 2 --retry-all-errors ` + --connect-timeout 20 --max-time 600 -o $Destination $Url + if ($LASTEXITCODE -ne 0) { + throw ("Download failed ({0}): {1}" -f $LASTEXITCODE, $Url) + } +} + +function Resolve-InnoCompiler([string]$RequestedPath) { + $candidates = @() + if ($RequestedPath) { + $candidates += $RequestedPath + } + $command = Get-Command ISCC.exe -ErrorAction SilentlyContinue + if ($command) { + $candidates += $command.Source + } + $candidates += @( + (Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe"), + (Join-Path ${env:ProgramFiles} "Inno Setup 6\ISCC.exe"), + (Join-Path $env:LOCALAPPDATA "Programs\Inno Setup 6\ISCC.exe") + ) + $resolved = $candidates | + Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) } | + Select-Object -First 1 + if (-not $resolved) { + throw "Inno Setup 6 was not found. Install it with: winget install --id JRSoftware.InnoSetup -e" + } + return (Resolve-Path -LiteralPath $resolved).Path +} + +function New-InstallerIcon([string]$SourcePng, [string]$DestinationIco) { + Add-Type -AssemblyName System.Drawing + if (-not ("BeautiCodeInstallerNative" -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +public static class BeautiCodeInstallerNative { + [DllImport("user32.dll", SetLastError = true)] + public static extern bool DestroyIcon(IntPtr handle); +} +'@ + } + + $source = $null + $bitmap = $null + $graphics = $null + $icon = $null + $stream = $null + $handle = [IntPtr]::Zero + try { + $source = [System.Drawing.Image]::FromFile($SourcePng) + $bitmap = New-Object System.Drawing.Bitmap(64, 64) + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $graphics.Clear([System.Drawing.Color]::Transparent) + $graphics.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality + $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality + $graphics.DrawImage($source, 0, 0, 64, 64) + $handle = $bitmap.GetHicon() + $icon = [System.Drawing.Icon]::FromHandle($handle) + $stream = [System.IO.File]::Open( + $DestinationIco, + [System.IO.FileMode]::Create, + [System.IO.FileAccess]::Write + ) + $icon.Save($stream) + } finally { + if ($null -ne $stream) { $stream.Dispose() } + if ($null -ne $icon) { $icon.Dispose() } + if ($handle -ne [IntPtr]::Zero) { + [void][BeautiCodeInstallerNative]::DestroyIcon($handle) + } + if ($null -ne $graphics) { $graphics.Dispose() } + if ($null -ne $bitmap) { $bitmap.Dispose() } + if ($null -ne $source) { $source.Dispose() } + } +} + +if (-not (Test-Path -LiteralPath $InstallerScript -PathType Leaf)) { + throw ("Missing Inno Setup script: {0}" -f $InstallerScript) +} + +$package = Get-Content -Raw -LiteralPath (Join-Path $RepoRoot "package.json") | + ConvertFrom-Json +$appVersion = [string]$package.version +if ($appVersion -notmatch "^\d+\.\d+\.\d+$") { + throw ("Installer requires a numeric three-part package version: {0}" -f $appVersion) +} + +Push-Location $RepoRoot +try { + & npm.cmd run build + if ($LASTEXITCODE -ne 0) { + throw ("npm run build failed ({0})" -f $LASTEXITCODE) + } +} finally { + Pop-Location +} + +Reset-Directory $StageRoot +Reset-Directory $OutputRoot +New-Item -ItemType Directory -Path $CacheRoot -Force | Out-Null + +$nodeArchiveName = "node-v{0}-win-x64.zip" -f $NodeVersion +$nodeArchive = Join-Path $CacheRoot $nodeArchiveName +$nodeShasums = Join-Path $CacheRoot ("SHASUMS256-{0}.txt" -f $NodeVersion) +$nodeBaseUrl = "https://nodejs.org/dist/v{0}" -f $NodeVersion +Invoke-VerifiedDownload "$nodeBaseUrl/$nodeArchiveName" $nodeArchive +Invoke-VerifiedDownload "$nodeBaseUrl/SHASUMS256.txt" $nodeShasums + +$checksumLine = Get-Content -LiteralPath $nodeShasums | + Where-Object { $_ -match ("^[a-fA-F0-9]{{64}}\s+{0}$" -f [regex]::Escape($nodeArchiveName)) } | + Select-Object -First 1 +if (-not $checksumLine) { + throw ("Official checksum was not found for {0}" -f $nodeArchiveName) +} +$expectedHash = ($checksumLine -split "\s+")[0].ToLowerInvariant() +$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $nodeArchive).Hash.ToLowerInvariant() +if ($actualHash -ne $expectedHash) { + throw ("Node.js archive checksum mismatch: expected {0}, got {1}" -f $expectedHash, $actualHash) +} +if ($NodeVersion -eq $PinnedNodeVersion -and $actualHash -ne $PinnedNodeArchiveSha256) { + throw ("Pinned Node.js archive checksum mismatch: expected {0}, got {1}" -f $PinnedNodeArchiveSha256, $actualHash) +} + +$nodeExtractRoot = Join-Path $ArtifactsRoot "node-extract" +Reset-Directory $nodeExtractRoot +Expand-Archive -LiteralPath $nodeArchive -DestinationPath $nodeExtractRoot -Force +$nodeDistribution = Join-Path $nodeExtractRoot ("node-v{0}-win-x64" -f $NodeVersion) + +foreach ($relativeDir in @( + "apps\tray", + "assets", + "scripts", + "packages\core", + "packages\adapter-codex", + "node_modules\@beauticode\core", + "runtime", + "licenses\node" + )) { + New-Item -ItemType Directory -Path (Join-Path $StageRoot $relativeDir) -Force | Out-Null +} + +foreach ($relativeFile in @( + "apps\tray\session-host.mjs", + "apps\tray\start-tray.ps1", + "assets\beauticode-icon-borderless.png", + "scripts\start-beauticode-engine.ps1", + "packages\core\package.json", + "packages\adapter-codex\package.json", + "LICENSE", + "NOTICE.md", + "THIRD_PARTY_NOTICES.md" + )) { + $source = Join-Path $RepoRoot $relativeFile + $destination = Join-Path $StageRoot $relativeFile + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + throw ("Missing runtime file: {0}" -f $source) + } + Copy-Item -LiteralPath $source -Destination $destination -Force +} + +Copy-RuntimeDirectory ` + (Join-Path $RepoRoot "packages\core\dist") ` + (Join-Path $StageRoot "packages\core\dist") +Copy-RuntimeDirectory ` + (Join-Path $RepoRoot "packages\adapter-codex\dist") ` + (Join-Path $StageRoot "packages\adapter-codex\dist") +Copy-Item -LiteralPath (Join-Path $RepoRoot "packages\core\package.json") ` + -Destination (Join-Path $StageRoot "node_modules\@beauticode\core\package.json") -Force +Copy-RuntimeDirectory ` + (Join-Path $RepoRoot "packages\core\dist") ` + (Join-Path $StageRoot "node_modules\@beauticode\core\dist") + +Copy-Item -LiteralPath (Join-Path $nodeDistribution "node.exe") ` + -Destination (Join-Path $StageRoot "runtime\node.exe") -Force +Copy-Item -LiteralPath (Join-Path $nodeDistribution "LICENSE") ` + -Destination (Join-Path $StageRoot "licenses\node\LICENSE") -Force + +New-InstallerIcon ` + (Join-Path $RepoRoot "assets\beauticode-icon-borderless.png") ` + (Join-Path $StageRoot "beauticode.ico") + +$commit = (& git -C $RepoRoot rev-parse --short=12 HEAD).Trim() +$trackedChanges = @(& git -C $RepoRoot status --porcelain --untracked-files=no) +$manifest = [ordered]@{ + schema = "beauticode.release/v1" + appVersion = $appVersion + nodeVersion = $NodeVersion + platform = "win-x64" + commit = $commit + dirty = ($trackedChanges.Count -gt 0) + builtAtUtc = [DateTime]::UtcNow.ToString("o") +} +$manifest | ConvertTo-Json | Set-Content ` + -LiteralPath (Join-Path $StageRoot "release-manifest.json") ` + -Encoding UTF8 + +$stagedNode = Join-Path $StageRoot "runtime\node.exe" +$stagedNodeVersion = (& $stagedNode --version).TrimStart("v") +if ($LASTEXITCODE -ne 0 -or $stagedNodeVersion -ne $NodeVersion) { + throw ("Staged Node.js version mismatch: expected {0}, got {1}" -f $NodeVersion, $stagedNodeVersion) +} +$adapterUrl = ([System.Uri](Join-Path $StageRoot "packages\adapter-codex\dist\index.js")).AbsoluteUri +$adapterProbe = & $stagedNode --input-type=module -e ` + "const m=await import(process.argv[1]); console.log(typeof m.BeautiSession);" ` + $adapterUrl +if ($LASTEXITCODE -ne 0 -or $adapterProbe -ne "function") { + throw ("Staged adapter import failed: {0}" -f $adapterProbe) +} + +$iscc = Resolve-InnoCompiler $InnoCompiler +& $iscc ` + ("/DMyAppVersion={0}" -f $appVersion) ` + ("/DStageDir={0}" -f $StageRoot) ` + ("/DOutputDir={0}" -f $OutputRoot) ` + $InstallerScript +if ($LASTEXITCODE -ne 0) { + throw ("Inno Setup compilation failed ({0})" -f $LASTEXITCODE) +} + +$installer = Get-ChildItem -LiteralPath $OutputRoot -Filter "beautiCode-Setup-*.exe" | + Sort-Object -Property LastWriteTimeUtc -Descending | + Select-Object -First 1 +if (-not $installer) { + throw "Inno Setup completed without producing an installer." +} + +$installerHash = Get-FileHash -Algorithm SHA256 -LiteralPath $installer.FullName +Write-Host "" +Write-Host "Windows installer created:" +Write-Host (" {0}" -f $installer.FullName) +Write-Host (" SHA256 {0}" -f $installerHash.Hash) diff --git a/scripts/start-beauticode-engine.ps1 b/scripts/start-beauticode-engine.ps1 index 2d164f2..35fa029 100644 --- a/scripts/start-beauticode-engine.ps1 +++ b/scripts/start-beauticode-engine.ps1 @@ -22,8 +22,10 @@ if ($Port -lt 0 -or $Port -gt 65535) { } $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $TrayScript = Join-Path $RepoRoot "apps\tray\start-tray.ps1" +$ReleaseManifest = Join-Path $RepoRoot "release-manifest.json" $CodexAppUserModelId = "OpenAI.Codex_2p2nqsd0c76g0!App" $LogPath = Join-Path $env:LOCALAPPDATA "beautiCode\engine-launcher.log" +$TrayMutexName = "Local\beautiCode.Engine.Tray.v1" function Write-BcLog([string]$Message) { try { @@ -96,9 +98,25 @@ function Find-BcCdpPortFast([int]$Preferred = 9335) { return 0 } +function Test-BcTrayRunningFast { + $mutex = $null + try { + $mutex = [System.Threading.Mutex]::OpenExisting($TrayMutexName) + return $true + } catch [System.Threading.WaitHandleCannotBeOpenedException] { + return $false + } catch { + # Fail open: the session-host injector lock still rejects duplicate owners. + return $false + } finally { + if ($null -ne $mutex) { + try { $mutex.Dispose() } catch {} + } + } +} + function Get-BcTrayPids { - # Narrow Get-CimInstance filter is still heavy; use process name + command line - # only when ForceRestart needs it. + # Win32_Process command-line inspection is intentionally ForceRestart-only. $pids = New-Object System.Collections.ArrayList try { Get-CimInstance Win32_Process -Filter "Name = 'powershell.exe' OR Name = 'pwsh.exe' OR Name = 'node.exe'" -ErrorAction SilentlyContinue | @@ -172,6 +190,9 @@ function Start-BcTray([int]$CdpPort) { [void]$argList.Add("-Port") [void]$argList.Add("$CdpPort") } + if (Test-Path -LiteralPath $ReleaseManifest -PathType Leaf) { + [void]$argList.Add("-SkipBuild") + } # The tray owns the source-vs-dist freshness check and rebuilds stale dist. $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = "powershell.exe" @@ -207,12 +228,10 @@ try { } Start-Sleep -Milliseconds 400 } else { - # Cheap duplicate check: if session-host already listening via our prior - # run, avoid Win32_Process scan when possible by looking for node+tray only - # when ForceRestart is off and user double-clicks. - $existing = @(Get-BcTrayPids) - if ($existing.Count -gt 0) { - Write-BcLog ("engine already running count={0}" -f $existing.Count) + # The tray owns this process-lifetime mutex. Checking it avoids the + # 0.3-1.0s Win32_Process scan on every normal launch. + if (Test-BcTrayRunningFast) { + Write-BcLog "engine already running mutex=True" # No modal dialog — tray icon is already the signal. Exit quietly. exit 0 } @@ -221,23 +240,9 @@ try { # 1) Fast CDP probe (preferred port only, ~100–300ms worst case miss). $cdpPort = Find-BcCdpPortFast -Preferred $Port - # 2) If no CDP, kick Codex launch WITHOUT waiting for it — tray starts now. - # session-host deferHostConnect + watch loop will attach when CDP appears. - # Tray "应用或重新应用" can also force launch/restart later. - if ($cdpPort -le 0 -and -not $NoLaunchCodex) { - Write-BcLog "no CDP yet - launching Codex in parallel with tray" - [void](Start-CodexWithCdpFast -CdpPort $Port) - # Tiny second chance (~0.6s) in case Codex CDP is already warm from a - # previous partial start; do NOT block for a full minute. - $deadline = (Get-Date).AddMilliseconds(600) - while ((Get-Date) -lt $deadline) { - $cdpPort = Find-BcCdpPortFast -Preferred $Port - if ($cdpPort -gt 0) { break } - Start-Sleep -Milliseconds 120 - } - } - - # 3) Start tray. Port 0 → session-host auto-discovers in background. + # 2) Spawn the tray before AppX lookup / Codex cold start. The session-host + # connects in the background, so neither task needs to block tray startup. + # Port 0 → session-host auto-discovers in background. # Preserve an explicitly requested port while Codex is still starting. $trayPort = if ($cdpPort -gt 0) { $cdpPort @@ -247,6 +252,15 @@ try { 0 } Start-BcTray -CdpPort $trayPort + Write-BcLog ("tray spawned in {0}ms" -f $sw.ElapsedMilliseconds) + + # 3) If no CDP, kick Codex launch after the tray process is already running. + # The session-host watch loop attaches when CDP appears. + if ($cdpPort -le 0 -and -not $NoLaunchCodex) { + Write-BcLog "no CDP yet - launching Codex after tray spawn" + [void](Start-CodexWithCdpFast -CdpPort $Port) + } + $sw.Stop() Write-BcLog ("launcher done in {0}ms cdp={1}" -f $sw.ElapsedMilliseconds, $cdpPort) exit 0