dsh-alk-pet 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -99,9 +99,22 @@ dsh plugin --profile web add /absolute/path/to/dsh-alk-pet
99
99
  - **不要**将 WebUI 暴露到不受信任的网络。
100
100
  - **不要**把插件的回环地址检查视为登录鉴权或访问控制。
101
101
 
102
- Windows 原生窗口通过 PowerShell 启动,并使用分层窗口实现逐像素透明。若系统策略禁止运行 PowerShell 脚本,插件会自动回退到网页内宠物。
102
+ Windows 原生窗口由随包的 `native/windows/pet.ps1`(Windows PowerShell 5.1 + WinForms 分层窗口)提供,使用分层窗口实现逐像素透明。DSH 启动子进程时会隐藏控制台,因此脚本完全通过标准输入/输出的 UTF-8 管道收发消息,不依赖控制台代码页;窗口真正可见后还会发送一次 `ready` 握手,Host 收到握手才隐藏网页内宠物,避免出现“原生窗口没起来、网页里也看不到宠物”的空窗。原生窗口异常退出时会自动重启两次;启动后若在 25 秒内没有变得可见,也会视为失败并自动回退到网页内宠物。
103
103
 
104
- 随包提供的 macOS Helper 当前为 Apple Silicon 版本。Intel Mac 会自动回退到网页版本,也可以使用本机工具链重新构建。Linux 不提供原生悬浮窗,仅使用 WebUI 页面内版本。原生窗口异常退出时会自动重启两次;仍无法运行则自动回退。
104
+ 如果 Windows 上只看到网页内宠物,通常说明原生 Helper 启动失败。请查看以下日志(首次写入时自动创建):
105
+
106
+ - `%LOCALAPPDATA%\dsh-alk-pet\helper.log`:Helper 自身的启动过程、窗口可见状态与异常堆栈。
107
+ - `%LOCALAPPDATA%\dsh-alk-pet\host.log`:Host 侧的启动参数、子进程 stderr 与重试记录。
108
+
109
+ 常见原因与处理方式:
110
+
111
+ - **系统策略禁止运行 PowerShell 脚本**(AppLocker、WDAC 或企业策略):需要为本插件放行 `powershell.exe`,否则只能继续使用网页内宠物。
112
+ - **PowerShell 不在 PATH 中**:插件会优先使用 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe`,也可以用环境变量 `DSH_ALK_PET_POWERSHELL` 指定绝对路径。
113
+ - **安全软件拦截脚本或弹出确认框**:请为插件目录和 PowerShell 放行。
114
+
115
+ Windows 版会把宠物位置保存到 `%LOCALAPPDATA%\dsh-alk-pet\window.json`。
116
+
117
+ 随包提供的 macOS Helper 当前为 Apple Silicon 版本。Intel Mac 会自动回退到网页版本,也可以使用本机工具链重新构建。Linux 不提供原生悬浮窗,仅使用 WebUI 页面内版本。
105
118
 
106
119
  ## 授权与声明
107
120
 
@@ -119,6 +132,7 @@ Windows 原生窗口通过 PowerShell 启动,并使用分层窗口实现逐像
119
132
  ```sh
120
133
  npm test # 运行测试
121
134
  npm run check:assets # 校验动画素材
135
+ npm run check:windows # 解析 Windows PowerShell 脚本并交叉编译其中的 C#(需要 pwsh / .NET SDK,缺失时自动跳过)
122
136
  npm run build:assets # 修改素材后重新生成资源
123
137
  npm run build:native # macOS:构建原生 Helper(需要 Swift 工具链)
124
138
  ```
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.5.0",
2
+ "version": "0.5.1",
3
3
  "frameMs": 83,
4
4
  "assetVersion": "5bbfc464d343",
5
5
  "clips": {
@@ -1,7 +1,44 @@
1
- Add-Type -AssemblyName System.Windows.Forms
2
- Add-Type -AssemblyName System.Drawing
3
- [Console]::InputEncoding = New-Object System.Text.UTF8Encoding($false)
4
- [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
1
+ # dsh-alk-pet Windows helper.
2
+ #
3
+ # The host launches this script with Node's windowsHide option, so the process
4
+ # has no console. Never assign [Console]::InputEncoding or [Console]::OutputEncoding
5
+ # here: those setters call SetConsoleCP/SetConsoleOutputCP and throw
6
+ # "The handle is invalid" when no console exists, which aborts the helper before
7
+ # it can create its window. The C# protocol code below reads and writes UTF-8
8
+ # directly on the inherited stdout/stdin pipes instead.
9
+ $ErrorActionPreference = 'Stop'
10
+
11
+ $helperLogPath = $null
12
+ try {
13
+ $helperLogDirectory = Join-Path $env:LOCALAPPDATA 'dsh-alk-pet'
14
+ $helperLogPath = Join-Path $helperLogDirectory 'helper.log'
15
+ if (-not (Test-Path -LiteralPath $helperLogDirectory)) {
16
+ New-Item -ItemType Directory -Force -Path $helperLogDirectory | Out-Null
17
+ }
18
+ if ((Test-Path -LiteralPath $helperLogPath) -and ((Get-Item -LiteralPath $helperLogPath).Length -gt 65536)) {
19
+ Remove-Item -LiteralPath $helperLogPath -Force
20
+ }
21
+ } catch {
22
+ $helperLogPath = $null
23
+ }
24
+
25
+ function Write-HelperLog([string]$Message) {
26
+ if (-not $helperLogPath) { return }
27
+ try {
28
+ Add-Content -LiteralPath $helperLogPath -Value ('[' + (Get-Date).ToString('yyyy-MM-dd HH:mm:ss.fff') + '] ' + $Message) -Encoding UTF8
29
+ } catch {
30
+ }
31
+ }
32
+
33
+ try {
34
+ Add-Type -AssemblyName System.Windows.Forms
35
+ Add-Type -AssemblyName System.Drawing
36
+ Add-Type -AssemblyName System.Web.Extensions
37
+ } catch {
38
+ Write-HelperLog ('failed to load assemblies: ' + $_.Exception.ToString())
39
+ [Console]::Error.WriteLine('[dsh-alk-pet] windows helper failed to load assemblies: ' + $_.Exception.Message)
40
+ exit 1
41
+ }
5
42
 
6
43
  $source = @'
7
44
  using System;
@@ -12,6 +49,7 @@ using System.Drawing.Imaging;
12
49
  using System.Drawing.Text;
13
50
  using System.IO;
14
51
  using System.Runtime.InteropServices;
52
+ using System.Text;
15
53
  using System.Threading;
16
54
  using System.Windows.Forms;
17
55
  using System.Web.Script.Serialization;
@@ -100,6 +138,15 @@ public class PetForm : Form {
100
138
  [DllImport("user32.dll")]
101
139
  private static extern bool SetProcessDpiAwarenessContext(IntPtr value);
102
140
 
141
+ [DllImport("user32.dll")]
142
+ private static extern bool ShowWindow(IntPtr hwnd, int nCmdShow);
143
+
144
+ [DllImport("user32.dll")]
145
+ private static extern bool SetWindowPos(IntPtr hwnd, IntPtr insertAfter, int x, int y, int cx, int cy, uint flags);
146
+
147
+ [DllImport("user32.dll")]
148
+ private static extern bool IsWindowVisible(IntPtr hwnd);
149
+
103
150
  // ------------------------------------------------------------------
104
151
  // Constants
105
152
  // ------------------------------------------------------------------
@@ -126,6 +173,15 @@ public class PetForm : Form {
126
173
  private const float BUBBLE_MIN_WIDTH = 164f;
127
174
  private const float BUBBLE_TITLE_PAD = 45f;
128
175
  private const float BUBBLE_DETAIL_PAD = 34f;
176
+ private const int SW_SHOWNOACTIVATE = 4;
177
+ private const uint SWP_NOSIZE = 0x0001;
178
+ private const uint SWP_NOMOVE = 0x0002;
179
+ private const uint SWP_NOACTIVATE = 0x0010;
180
+ private const uint SWP_SHOWWINDOW = 0x0040;
181
+ private static readonly IntPtr HWND_TOPMOST = new IntPtr(-1);
182
+ // Node's windowsHide startup info carries SW_HIDE, which Windows applies to
183
+ // the first ShowWindow call. Retry the explicit show for a moment afterwards.
184
+ private const int VISIBILITY_RETRY_TICKS = 25;
129
185
 
130
186
  // ------------------------------------------------------------------
131
187
  // Persisted / injected state
@@ -136,9 +192,9 @@ public class PetForm : Form {
136
192
  private ToolTip chipToolTip;
137
193
 
138
194
  private string petState = "idle";
139
- private string title = "待机中";
140
- private string status = "待机中";
141
- private string detail = "待机中";
195
+ private string title = "\u5F85\u673A\u4E2D";
196
+ private string status = "\u5F85\u673A\u4E2D";
197
+ private string detail = "\u5F85\u673A\u4E2D";
142
198
  private bool showBubble = true;
143
199
  private bool viewed = false;
144
200
  private bool reducedMotion = false;
@@ -150,6 +206,9 @@ public class PetForm : Form {
150
206
  private string assetVersion = ""; // parsed for protocol completeness; native rendering ignores it
151
207
  private string animationState = "idle";
152
208
  private int frameNumber = 1;
209
+ private StreamWriter protocolOutput = null;
210
+ private int visibilityAttempts = 0;
211
+ private bool readyAnnounced = false;
153
212
 
154
213
  // ------------------------------------------------------------------
155
214
  // DPI / layout
@@ -299,6 +358,7 @@ public class PetForm : Form {
299
358
  base.OnShown(e);
300
359
  try {
301
360
  TopMost = true;
361
+ ForceVisible();
302
362
  RenderFrame();
303
363
  } catch (Exception) {
304
364
  }
@@ -420,9 +480,14 @@ public class PetForm : Form {
420
480
  // Dedicated parser instance: the reader thread and the UI thread must not
421
481
  // share the same JavaScriptSerializer instance.
422
482
  JavaScriptSerializer parser = new JavaScriptSerializer();
483
+ StreamReader reader = null;
423
484
  try {
485
+ // Read UTF-8 directly from the inherited stdin pipe. Relying on
486
+ // Console.ReadLine would require a console input code page, which
487
+ // does not exist for a windowsHide child process.
488
+ reader = new StreamReader(Console.OpenStandardInput(), new UTF8Encoding(false), true);
424
489
  string line;
425
- while ((line = Console.ReadLine()) != null) {
490
+ while ((line = reader.ReadLine()) != null) {
426
491
  Dictionary<string, object> data = null;
427
492
  try {
428
493
  data = parser.DeserializeObject(line) as Dictionary<string, object>;
@@ -439,6 +504,11 @@ public class PetForm : Form {
439
504
  }));
440
505
  }
441
506
  } catch (Exception) {
507
+ } finally {
508
+ try {
509
+ if (reader != null) reader.Dispose();
510
+ } catch (Exception) {
511
+ }
442
512
  }
443
513
  InvokeOnUi(new Action(delegate {
444
514
  try {
@@ -473,7 +543,7 @@ public class PetForm : Form {
473
543
  frameNumber = 1;
474
544
  }
475
545
  }
476
- if (data.TryGetValue("title", out value)) title = value == null ? "当前任务" : Convert.ToString(value);
546
+ if (data.TryGetValue("title", out value)) title = value == null ? "\u5F53\u524D\u4EFB\u52A1" : Convert.ToString(value);
477
547
  if (data.TryGetValue("label", out value)) status = value == null ? "" : Convert.ToString(value);
478
548
  if (data.TryGetValue("detail", out value)) detail = value == null ? "" : Convert.ToString(value);
479
549
  if (data.TryGetValue("showBubble", out value) && value != null) showBubble = SafeBool(value, showBubble);
@@ -574,7 +644,12 @@ public class PetForm : Form {
574
644
  }
575
645
 
576
646
  private void OnAnimationTick(object sender, EventArgs e) {
577
- if (closing || reducedMotion) return;
647
+ if (closing) return;
648
+ if (!readyAnnounced && visibilityAttempts < VISIBILITY_RETRY_TICKS) {
649
+ visibilityAttempts++;
650
+ ForceVisible();
651
+ }
652
+ if (reducedMotion) return;
578
653
  int count;
579
654
  if (!clips.TryGetValue(animationState, out count) || count < 1) count = 1;
580
655
  if (frameNumber < count) {
@@ -1344,7 +1419,7 @@ public class PetForm : Form {
1344
1419
  string value = Convert.ToString(raw);
1345
1420
  if (!String.IsNullOrEmpty(value)) return value;
1346
1421
  }
1347
- return "当前任务";
1422
+ return "\u5F53\u524D\u4EFB\u52A1";
1348
1423
  }
1349
1424
 
1350
1425
  private int BubbleTaskWidth(Dictionary<string, object> task, int titlePad, int detailPad) {
@@ -1529,17 +1604,51 @@ public class PetForm : Form {
1529
1604
 
1530
1605
  private void SendJson(Dictionary<string, object> payload) {
1531
1606
  try {
1532
- Console.WriteLine(json.Serialize(payload));
1533
- Console.Out.Flush();
1607
+ if (protocolOutput == null) {
1608
+ protocolOutput = new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false));
1609
+ protocolOutput.AutoFlush = true;
1610
+ }
1611
+ protocolOutput.WriteLine(json.Serialize(payload));
1534
1612
  } catch (Exception) {
1535
1613
  }
1536
1614
  }
1537
1615
 
1616
+ /**
1617
+ * Windows applies the STARTUPINFO wShowWindow value to the first ShowWindow
1618
+ * of the process. Node's windowsHide passes SW_HIDE, so the layered pet
1619
+ * window would be created but stay invisible without an explicit show.
1620
+ */
1621
+ private void ForceVisible() {
1622
+ if (closing || !IsHandleCreated) return;
1623
+ try {
1624
+ ShowWindow(Handle, SW_SHOWNOACTIVATE);
1625
+ SetWindowPos(Handle, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW);
1626
+ } catch (Exception) {
1627
+ }
1628
+ AnnounceReadyIfVisible();
1629
+ }
1630
+
1631
+ private void AnnounceReadyIfVisible() {
1632
+ if (readyAnnounced || closing || !IsHandleCreated) return;
1633
+ try {
1634
+ if (!IsWindowVisible(Handle)) return;
1635
+ } catch (Exception) {
1636
+ return;
1637
+ }
1638
+ readyAnnounced = true;
1639
+ Dictionary<string, object> payload = new Dictionary<string, object>();
1640
+ payload["type"] = "ready";
1641
+ payload["pid"] = System.Diagnostics.Process.GetCurrentProcess().Id;
1642
+ payload["width"] = Width;
1643
+ payload["height"] = Height;
1644
+ SendJson(payload);
1645
+ }
1646
+
1538
1647
  private void UpdateChipTooltip(int index) {
1539
1648
  try {
1540
1649
  if (chipToolTip == null) return;
1541
1650
  if (index >= 0 && index < tasks.Count) {
1542
- chipToolTip.SetToolTip(this, TaskTitleOf(tasks[index]) + " · " + StatusLabelOf(StatusOf(tasks[index])));
1651
+ chipToolTip.SetToolTip(this, TaskTitleOf(tasks[index]) + " \u00B7 " + StatusLabelOf(StatusOf(tasks[index])));
1543
1652
  } else {
1544
1653
  chipToolTip.SetToolTip(this, "");
1545
1654
  }
@@ -1548,11 +1657,11 @@ public class PetForm : Form {
1548
1657
  }
1549
1658
 
1550
1659
  private static string StatusLabelOf(string chipStatus) {
1551
- if (chipStatus == "running") return "运行中";
1552
- if (chipStatus == "waiting") return "等待确认";
1553
- if (chipStatus == "success") return "已完成";
1554
- if (chipStatus == "error") return "失败";
1555
- return "待机";
1660
+ if (chipStatus == "running") return "\u8FD0\u884C\u4E2D";
1661
+ if (chipStatus == "waiting") return "\u7B49\u5F85\u786E\u8BA4";
1662
+ if (chipStatus == "success") return "\u5DF2\u5B8C\u6210";
1663
+ if (chipStatus == "error") return "\u5931\u8D25";
1664
+ return "\u5F85\u673A";
1556
1665
  }
1557
1666
 
1558
1667
  // ------------------------------------------------------------------
@@ -1648,13 +1757,34 @@ public class PetForm : Form {
1648
1757
  }
1649
1758
  '@
1650
1759
 
1760
+ $petForm = $null
1651
1761
  try {
1652
- Add-Type -TypeDefinition $source -ReferencedAssemblies 'System.Windows.Forms','System.Drawing','System.Web.Extensions'
1762
+ $references = @(
1763
+ [System.Windows.Forms.Form].Assembly.Location,
1764
+ [System.Drawing.Bitmap].Assembly.Location,
1765
+ [System.Web.Script.Serialization.JavaScriptSerializer].Assembly.Location,
1766
+ [System.ComponentModel.Component].Assembly.Location
1767
+ ) | Where-Object { $_ } | Select-Object -Unique
1768
+
1769
+ Add-Type -TypeDefinition $source -ReferencedAssemblies $references
1653
1770
  [PetForm]::SetupDpiAwareness()
1654
1771
  [System.Windows.Forms.Application]::EnableVisualStyles()
1655
- $petForm = New-Object PetForm -ArgumentList (Join-Path $PSScriptRoot '..\..\assets\animations')
1772
+
1773
+ $assetDirectory = Join-Path $PSScriptRoot '..\..\assets\animations'
1774
+ if (-not (Test-Path -LiteralPath $assetDirectory)) {
1775
+ Write-HelperLog ('asset directory missing: ' + $assetDirectory)
1776
+ }
1777
+ Write-HelperLog ('starting windows helper; pid=' + $PID + '; assets=' + $assetDirectory)
1778
+ $petForm = New-Object PetForm -ArgumentList $assetDirectory
1656
1779
  [System.Windows.Forms.Application]::Run($petForm)
1780
+ Write-HelperLog 'windows helper window closed'
1657
1781
  } catch {
1658
- [Console]::Error.WriteLine('[dsh-alk-pet] windows helper failed: ' + $_.Exception.ToString())
1782
+ $failure = $_.Exception.ToString()
1783
+ Write-HelperLog ('fatal: ' + $failure)
1784
+ [Console]::Error.WriteLine('[dsh-alk-pet] windows helper failed: ' + $failure)
1659
1785
  exit 1
1786
+ } finally {
1787
+ if ($petForm -ne $null) {
1788
+ try { $petForm.Dispose() } catch { }
1789
+ }
1660
1790
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-alk-pet",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Unofficial desktop pet plugin for DeepSeek Harness (DSH) with native floating windows on macOS/Windows.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -46,6 +46,7 @@
46
46
  "test": "node --test test/*.test.js",
47
47
  "build:assets": "node scripts/build-assets.mjs",
48
48
  "check:assets": "node scripts/build-assets.mjs --check",
49
+ "check:windows": "node scripts/check-windows-helper.mjs",
49
50
  "build:native": "node scripts/build-native.mjs",
50
51
  "build": "npm run build:assets && npm run build:native"
51
52
  },
@@ -0,0 +1,49 @@
1
+ import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ const MAX_LOG_BYTES = 256 * 1024
5
+
6
+ function logPathFor(platform, env) {
7
+ const home = env.HOME || env.USERPROFILE || ''
8
+ if (platform === 'win32') {
9
+ const base = env.LOCALAPPDATA || join(home, 'AppData', 'Local')
10
+ return join(base, 'dsh-alk-pet', 'host.log')
11
+ }
12
+ if (platform === 'darwin') {
13
+ return join(home, 'Library', 'Logs', 'dsh-alk-pet', 'host.log')
14
+ }
15
+ return join(env.XDG_STATE_HOME || join(home, '.local', 'state'), 'dsh-alk-pet', 'host.log')
16
+ }
17
+
18
+ /**
19
+ * Best-effort append-only log for native helper diagnostics.
20
+ *
21
+ * The DSH desktop shell discards a plugin host's console output, so a Windows
22
+ * user whose native helper keeps falling back would otherwise have no clue why.
23
+ * The log is created lazily on the first write; failures never throw.
24
+ */
25
+ export function createHelperLog({ platform = process.platform, env = process.env, timestamp = () => new Date().toISOString() } = {}) {
26
+ let path = null
27
+ let prepared = false
28
+ return function log(message) {
29
+ const line = `[${timestamp()}] ${String(message ?? '').replace(/\s+$/u, '')}\n`
30
+ try {
31
+ if (!path) {
32
+ path = logPathFor(platform, env)
33
+ if (!path) return
34
+ }
35
+ if (!prepared) {
36
+ mkdirSync(dirname(path), { recursive: true })
37
+ prepared = true
38
+ }
39
+ try {
40
+ if (statSync(path).size > MAX_LOG_BYTES) renameSync(path, `${path}.1`)
41
+ } catch {}
42
+ appendFileSync(path, line)
43
+ } catch {
44
+ // Diagnostics must never break the pet.
45
+ }
46
+ }
47
+ }
48
+
49
+ export { logPathFor }
package/src/index.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { loadManifest } from './assets.js'
5
+ import { createHelperLog } from './helper-log.js'
5
6
  import { startNative } from './native.js'
6
7
  import { HOVER_TTL_MS, PetRuntime } from './pet-runtime.js'
7
8
 
@@ -68,13 +69,15 @@ export function apply(ctx, config = {}, options = {}) {
68
69
  const clients = new Set()
69
70
  const imageCache = new Map()
70
71
  let native = null
72
+ let nativeReady = false
71
73
  let carouselTimer = null
72
74
  let nextDelay = 2000
73
75
  let hoverSweep = null
74
76
  let heartbeat = null
75
77
  let disposed = false
76
78
 
77
- const snapshot = () => runtime.snapshot({ native: native !== null })
79
+ const nativeActive = () => native !== null && (!native.requiresReady || nativeReady)
80
+ const snapshot = () => runtime.snapshot({ native: nativeActive() })
78
81
 
79
82
  const broadcast = () => {
80
83
  const current = snapshot()
@@ -120,15 +123,31 @@ export function apply(ctx, config = {}, options = {}) {
120
123
  }
121
124
  }
122
125
 
126
+ const logHelper = options.log ?? createHelperLog()
127
+ const log = (message) => {
128
+ console.warn('[dsh-alk-pet]', message)
129
+ logHelper(message)
130
+ }
131
+
123
132
  if (runtime.config.enabled) {
124
133
  native = startHelper({
125
134
  onAction,
126
- onLog: (message) => console.warn('[dsh-alk-pet]', message),
135
+ onLog: log,
136
+ onReady: () => {
137
+ nativeReady = true
138
+ broadcast()
139
+ },
140
+ onDisconnect: () => {
141
+ if (!nativeReady) return
142
+ nativeReady = false
143
+ broadcast()
144
+ },
127
145
  onExit: (error) => {
128
146
  native = null
147
+ nativeReady = false
129
148
  runtime.setHover('native', false)
130
149
  broadcast()
131
- console.warn('[dsh-alk-pet] native helper unavailable:', error?.message ?? error)
150
+ log(`native helper unavailable: ${error?.message ?? error}`)
132
151
  },
133
152
  })
134
153
  }
@@ -167,7 +186,7 @@ export function apply(ctx, config = {}, options = {}) {
167
186
  function configHandler(req, res) {
168
187
  if (!local(req)) { res.writeHead(403); res.end(); return }
169
188
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
170
- res.end(JSON.stringify({ ...runtime.configSnapshot(), native: native !== null }))
189
+ res.end(JSON.stringify({ ...runtime.configSnapshot(), native: nativeActive() }))
171
190
  }
172
191
 
173
192
  function image(req, res) {
@@ -250,6 +269,7 @@ export function apply(ctx, config = {}, options = {}) {
250
269
  onEvent?.()
251
270
  native?.stop()
252
271
  native = null
272
+ nativeReady = false
253
273
  clients.forEach((res) => { try { res.end() } catch {} })
254
274
  clients.clear()
255
275
  imageCache.clear()
package/src/native.js CHANGED
@@ -1,16 +1,22 @@
1
1
  import { spawn } from 'node:child_process'
2
- import { accessSync, closeSync, constants, openSync, readSync } from 'node:fs'
2
+ import { accessSync, closeSync, constants, existsSync, openSync, readSync } from 'node:fs'
3
3
  import { dirname, join } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
 
6
6
  const root = dirname(dirname(fileURLToPath(import.meta.url)))
7
7
  const helperPath = join(root, 'runtime/bin/darwin/dsh-alk-pet-helper')
8
+ const windowsScriptPath = join(root, 'native/windows/pet.ps1')
8
9
  const assetsPath = join(root, 'assets/animations')
9
10
 
10
11
  const MACHO_MAGIC_64 = 0xfeedfacf
11
12
  const CPU_TYPE_ARM64 = 0x0100000c
12
13
  const CPU_TYPE_X64 = 0x01000007
13
14
 
15
+ const DEFAULT_READY_TIMEOUT_MS = 25_000
16
+ const RESTART_DELAY_MS = 1200
17
+ const STDERR_LIMIT = 4096
18
+ const OUTPUT_LINE_LIMIT = 65536
19
+
14
20
  function isExecutable(path) {
15
21
  try {
16
22
  accessSync(path, constants.X_OK)
@@ -35,29 +41,90 @@ function machOArch(path) {
35
41
  }
36
42
  }
37
43
 
44
+ function truncate(value, limit = 400) {
45
+ const text = String(value ?? '').replace(/\s+$/u, '')
46
+ return text.length > limit ? `${text.slice(0, limit)}...` : text
47
+ }
48
+
49
+ /**
50
+ * Resolves the Windows PowerShell executable without relying only on PATH.
51
+ *
52
+ * The DSH desktop host is started by the Electron shell with a curated
53
+ * environment, so `powershell.exe` is not guaranteed to be resolvable by name.
54
+ * The absolute Windows PowerShell location is tried first; a bare name is kept
55
+ * as the last resort so the spawn error still surfaces through `onLog`.
56
+ */
57
+ export function resolveWindowsPowerShell({ env = process.env, fileExists = existsSync } = {}) {
58
+ const explicit = env.DSH_ALK_PET_POWERSHELL
59
+ if (typeof explicit === 'string' && explicit.trim()) {
60
+ return fileExists(explicit.trim()) ? explicit.trim() : 'powershell.exe'
61
+ }
62
+ const systemRoot = env.SystemRoot || env.windir || 'C:\\Windows'
63
+ const candidates = [
64
+ join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
65
+ join(systemRoot, 'SysWOW64', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
66
+ ]
67
+ for (const candidate of candidates) {
68
+ if (fileExists(candidate)) return candidate
69
+ }
70
+ return 'powershell.exe'
71
+ }
72
+
73
+ /** Arguments used to launch the Windows WinForms helper script. */
74
+ export function windowsHelperArgs(scriptPath = windowsScriptPath) {
75
+ return ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath]
76
+ }
77
+
38
78
  /**
39
- * Starts the platform native pet window. The helper is restarted a couple of
40
- * times when it exits unexpectedly; onExit fires once when it is given up
41
- * (or was never startable).
79
+ * Starts the platform native pet window.
80
+ *
81
+ * The helper is restarted a couple of times when it exits unexpectedly. On
82
+ * Windows the helper must also announce itself with a `{"type":"ready"}` line
83
+ * once its layered window is actually visible; a helper that stays alive but
84
+ * never becomes visible is killed by the startup watchdog so the in-page
85
+ * fallback can take over (the window is launched with `windowsHide`, and a
86
+ * silent child process would otherwise leave the user with no pet at all).
87
+ *
88
+ * `onExit` fires once when the helper is given up (or was never startable),
89
+ * `onReady` fires when the native window is usable, and `onDisconnect` fires
90
+ * on every unexpected helper exit before a restart is attempted.
42
91
  */
43
- export function startNative({ onExit = () => {}, onAction = () => {}, onLog = () => {}, restarts = 2 } = {}) {
92
+ export function startNative({
93
+ onExit = () => {},
94
+ onAction = () => {},
95
+ onLog = () => {},
96
+ onReady = () => {},
97
+ onDisconnect = () => {},
98
+ restarts = 2,
99
+ readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS,
100
+ restartDelayMs = RESTART_DELAY_MS,
101
+ platform = process.platform,
102
+ spawnProcess = spawn,
103
+ fileExists = existsSync,
104
+ darwinHelperPath = helperPath,
105
+ darwinAssetsPath = assetsPath,
106
+ } = {}) {
44
107
  let command
45
108
  let args
46
- if (process.platform === 'darwin') {
47
- if (!isExecutable(helperPath)) {
109
+ if (platform === 'darwin') {
110
+ if (!isExecutable(darwinHelperPath)) {
48
111
  onLog('helper missing or not executable; using in-page overlay')
49
112
  return null
50
113
  }
51
- const arch = machOArch(helperPath)
114
+ const arch = machOArch(darwinHelperPath)
52
115
  if (arch && arch !== process.arch) {
53
116
  onLog(`helper is built for ${arch} but this host is ${process.arch}; using in-page overlay`)
54
117
  return null
55
118
  }
56
- command = helperPath
57
- args = [assetsPath]
58
- } else if (process.platform === 'win32') {
59
- command = 'powershell.exe'
60
- args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(root, 'native/windows/pet.ps1')]
119
+ command = darwinHelperPath
120
+ args = [darwinAssetsPath]
121
+ } else if (platform === 'win32') {
122
+ if (!fileExists(windowsScriptPath)) {
123
+ onLog(`windows helper script missing: ${windowsScriptPath}; using in-page overlay`)
124
+ return null
125
+ }
126
+ command = resolveWindowsPowerShell({ fileExists })
127
+ args = windowsHelperArgs(windowsScriptPath)
61
128
  } else {
62
129
  return null
63
130
  }
@@ -66,52 +133,114 @@ export function startNative({ onExit = () => {}, onAction = () => {}, onLog = ()
66
133
  let stopped = false
67
134
  let attempt = 0
68
135
  let restartTimer = null
136
+ let ready = false
137
+ let lastStderr = ''
69
138
 
70
139
  const spawnOnce = () => {
71
140
  if (stopped) return
72
141
  let finished = false
142
+ let readyTimer = null
73
143
  let output = ''
74
- const processHandle = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
144
+ let stderrTail = ''
145
+ const processHandle = spawnProcess(command, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
75
146
  const spawnedAt = Date.now()
76
147
  child = processHandle
148
+ ready = false
149
+
150
+ const clearReadyTimer = () => {
151
+ if (readyTimer !== null) {
152
+ clearTimeout(readyTimer)
153
+ readyTimer = null
154
+ }
155
+ }
77
156
 
78
157
  const fail = (error) => {
79
158
  if (finished) return
80
159
  finished = true
160
+ clearReadyTimer()
81
161
  if (child === processHandle) child = null
82
162
  if (stopped) return
163
+ ready = false
164
+ onDisconnect(error)
83
165
  // A helper that stays alive for a while earns a fresh restart budget.
84
166
  if (Date.now() - spawnedAt > 30_000) attempt = 0
85
167
  attempt += 1
168
+ const detail = stderrTail.trim() || lastStderr
169
+ if (stderrTail.trim()) lastStderr = stderrTail.trim()
170
+ const message = detail ? `${error?.message ?? 'unknown'}: ${truncate(detail, 800)}` : error?.message ?? 'unknown'
86
171
  if (attempt <= restarts) {
87
- const delay = 1200 * attempt
88
- onLog(`helper stopped (${error?.message ?? 'unknown'}); restarting in ${delay}ms`)
172
+ const delay = restartDelayMs * attempt
173
+ onLog(`helper stopped (${message}); restarting in ${delay}ms`)
89
174
  restartTimer = setTimeout(spawnOnce, delay)
90
175
  restartTimer.unref?.()
91
176
  } else {
92
- onExit(error)
177
+ const finalError = new Error(message)
178
+ onLog(`native helper unavailable: ${message}`)
179
+ onExit(finalError)
93
180
  }
94
181
  }
95
182
 
183
+ const announceReady = () => {
184
+ if (ready) return
185
+ ready = true
186
+ clearReadyTimer()
187
+ onLog(`native helper ready after ${Date.now() - spawnedAt}ms`)
188
+ onReady()
189
+ }
190
+
96
191
  processHandle.stdout.on('data', (chunk) => {
97
192
  output += String(chunk)
98
- if (output.length > 65536) output = ''
193
+ if (output.length > OUTPUT_LINE_LIMIT) output = output.slice(-OUTPUT_LINE_LIMIT)
99
194
  let end
100
195
  while ((end = output.indexOf('\n')) >= 0) {
101
- const line = output.slice(0, end)
196
+ const line = output.slice(0, end).trim()
102
197
  output = output.slice(end + 1)
103
- try { onAction(JSON.parse(line)) } catch {}
198
+ if (!line) continue
199
+ let action
200
+ try {
201
+ action = JSON.parse(line)
202
+ } catch {
203
+ onLog(`unexpected windows helper output: ${truncate(line, 200)}`)
204
+ continue
205
+ }
206
+ if (action?.type === 'ready') {
207
+ announceReady()
208
+ continue
209
+ }
210
+ try { onAction(action) } catch {}
104
211
  }
105
212
  })
106
213
  processHandle.stdin.on('error', () => {})
107
- processHandle.stderr.on('data', (data) => onLog(String(data).trim()))
214
+ processHandle.stderr.on('data', (data) => {
215
+ const text = String(data)
216
+ stderrTail = (stderrTail + text).slice(-STDERR_LIMIT)
217
+ const trimmed = text.trim()
218
+ if (trimmed) onLog(trimmed)
219
+ })
108
220
  processHandle.on('error', fail)
109
221
  processHandle.on('exit', (code, signal) => fail(new Error(`helper exited (${code ?? signal})`)))
222
+
223
+ // Windows only: a hidden helper that never announces itself would otherwise
224
+ // suppress the in-page fallback forever.
225
+ if (platform === 'win32' && readyTimeoutMs > 0) {
226
+ readyTimer = setTimeout(() => {
227
+ if (finished || stopped || ready) return
228
+ const detail = stderrTail.trim()
229
+ const error = new Error(`helper did not become visible within ${readyTimeoutMs}ms${detail ? `: ${truncate(detail, 800)}` : ''}`)
230
+ onLog(error.message)
231
+ fail(error)
232
+ try { processHandle.kill() } catch {}
233
+ }, readyTimeoutMs)
234
+ readyTimer.unref?.()
235
+ }
110
236
  }
111
237
 
112
238
  spawnOnce()
113
239
 
114
240
  return {
241
+ // Windows helpers must announce readiness before the in-page overlay hides.
242
+ requiresReady: platform === 'win32',
243
+ isReady: () => ready,
115
244
  send(message) {
116
245
  if (!child || child.exitCode !== null || child.stdin.destroyed) return
117
246
  try { child.stdin.write(`${JSON.stringify(message)}\n`) } catch {}