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 +16 -2
- package/assets/animations/manifest.json +1 -1
- package/native/windows/pet.ps1 +152 -22
- package/package.json +2 -1
- package/src/helper-log.js +49 -0
- package/src/index.js +24 -4
- package/src/native.js +150 -21
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
|
|
102
|
+
Windows 原生窗口由随包的 `native/windows/pet.ps1`(Windows PowerShell 5.1 + WinForms 分层窗口)提供,使用分层窗口实现逐像素透明。DSH 启动子进程时会隐藏控制台,因此脚本完全通过标准输入/输出的 UTF-8 管道收发消息,不依赖控制台代码页;窗口真正可见后还会发送一次 `ready` 握手,Host 收到握手才隐藏网页内宠物,避免出现“原生窗口没起来、网页里也看不到宠物”的空窗。原生窗口异常退出时会自动重启两次;启动后若在 25 秒内没有变得可见,也会视为失败并自动回退到网页内宠物。
|
|
103
103
|
|
|
104
|
-
|
|
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
|
```
|
package/native/windows/pet.ps1
CHANGED
|
@@ -1,7 +1,44 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
[Console]::
|
|
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 =
|
|
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 ? "
|
|
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
|
|
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
|
-
|
|
1533
|
-
|
|
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]) + "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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({
|
|
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 (
|
|
47
|
-
if (!isExecutable(
|
|
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(
|
|
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 =
|
|
57
|
-
args = [
|
|
58
|
-
} else if (
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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 =
|
|
88
|
-
onLog(`helper stopped (${
|
|
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
|
-
|
|
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 >
|
|
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
|
-
|
|
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) =>
|
|
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 {}
|