@xevy/heny-connect 0.1.0 → 0.2.0

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
@@ -23,6 +23,34 @@ npx @xevy/heny-connect run
23
23
 
24
24
  The process sends a heartbeat every 30 seconds. Keep it running for the device to remain available in Heny.
25
25
 
26
+ ## Windows system tray
27
+
28
+ Install the tray companion after pairing:
29
+
30
+ ```powershell
31
+ heny-connect install
32
+ ```
33
+
34
+ Heny Connect starts at Windows sign-in and runs without an open terminal. The tray menu shows the connection state and provides these controls:
35
+
36
+ - Open Heny
37
+ - Pause or resume the connection
38
+ - Reconnect now
39
+ - Turn start-at-sign-in on or off
40
+ - Quit until the next sign-in
41
+
42
+ To start the tray for the current session without installing start-at-sign-in:
43
+
44
+ ```powershell
45
+ heny-connect tray
46
+ ```
47
+
48
+ To remove start-at-sign-in:
49
+
50
+ ```powershell
51
+ heny-connect uninstall
52
+ ```
53
+
26
54
  ## Check status
27
55
 
28
56
  ```bash
@@ -9,11 +9,15 @@
9
9
  *
10
10
  * Pairing state is stored in ~/.heny-connect.json (mode 600).
11
11
  */
12
+ import { spawn, spawnSync } from "node:child_process";
12
13
  import { chmod, readFile, writeFile } from "node:fs/promises";
13
14
  import { homedir, hostname, platform, release } from "node:os";
14
- import { join } from "node:path";
15
+ import { dirname, join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
15
17
 
16
18
  const STATE_FILE = join(process.env.HENY_CONNECT_HOME || homedir(), ".heny-connect.json");
19
+ const CLI_FILE = fileURLToPath(import.meta.url);
20
+ const TRAY_SCRIPT = join(dirname(CLI_FILE), "..", "windows", "heny-connect-tray.ps1");
17
21
  const args = process.argv.slice(2);
18
22
  const command = args[0] || "help";
19
23
  const opt = (name, fallback) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : fallback; };
@@ -46,7 +50,15 @@ async function pair() {
46
50
  }
47
51
 
48
52
  async function heartbeat(state, detail) {
49
- return call(state.server, "/api/devices/heartbeat", { state: "available", detail }, state.token);
53
+ return call(state.server, "/api/devices/heartbeat", { state: detail.state, detail: detail.message }, state.token);
54
+ }
55
+
56
+ async function sendHeartbeat(stateName = "available", message = `${hostname()}: browser ready`) {
57
+ const state = await loadState();
58
+ if (!state) { console.error("Not paired. Run: heny-connect pair --code … --server …"); process.exitCode = 2; return false; }
59
+ if (!["online", "available", "offline"].includes(stateName)) { console.error("State must be online, available, or offline."); process.exitCode = 2; return false; }
60
+ await heartbeat(state, { state: stateName, message });
61
+ return true;
50
62
  }
51
63
 
52
64
  async function run() {
@@ -55,7 +67,7 @@ async function run() {
55
67
  const every = Number(opt("every", 30)) * 1000;
56
68
  const beats = Number(opt("beats", 0)); let count = 0;
57
69
  const tick = async () => {
58
- try { await heartbeat(state, `${hostname()}: browser ready`); count += 1; console.log(`${new Date().toISOString()} heartbeat ok (${count})`); }
70
+ try { await heartbeat(state, { state: "available", message: `${hostname()}: browser ready` }); count += 1; console.log(`${new Date().toISOString()} heartbeat ok (${count})`); }
59
71
  catch (err) { console.error(`${new Date().toISOString()} heartbeat failed: ${err.message}`); if (/Unknown device token/.test(err.message)) process.exit(3); }
60
72
  if (beats && count >= beats) process.exit(0);
61
73
  };
@@ -63,6 +75,12 @@ async function run() {
63
75
  if (!beats || count < beats) setInterval(tick, every);
64
76
  }
65
77
 
78
+ async function heartbeatOnce() {
79
+ const stateName = opt("state", "available");
80
+ const message = opt("detail", stateName === "offline" ? `${hostname()}: paused locally` : `${hostname()}: browser ready`);
81
+ if (await sendHeartbeat(stateName, message)) console.log(`Heartbeat reported ${stateName}.`);
82
+ }
83
+
66
84
  async function status() {
67
85
  const state = await loadState();
68
86
  if (!state) { console.log("Not paired."); return; }
@@ -70,5 +88,47 @@ async function status() {
70
88
  console.log(res.ok ? JSON.stringify(await res.json(), null, 2) : `Server answered HTTP ${res.status}`);
71
89
  }
72
90
 
73
- const commands = { pair, run, status, help: async () => console.log("Commands: pair --code <6 digits> --server <url> [--run] | run [--every 30] [--beats N] | status") };
74
- (commands[command] || commands.help)().catch((err) => { console.error(err.message); process.exit(1); });
91
+ function requireWindows() {
92
+ if (platform() === "win32") return true;
93
+ console.error("The system tray is available on Windows.");
94
+ process.exitCode = 2;
95
+ return false;
96
+ }
97
+
98
+ function trayArgs(mode) {
99
+ const values = ["-NoLogo", "-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File", TRAY_SCRIPT, "-NodePath", process.execPath, "-CliPath", CLI_FILE];
100
+ if (mode === "install") values.push("-Install");
101
+ if (mode === "uninstall") values.push("-Uninstall");
102
+ return values;
103
+ }
104
+
105
+ async function tray() {
106
+ if (!requireWindows()) return;
107
+ if (!await loadState()) { console.error("Not paired. Pair this computer before starting the tray."); process.exitCode = 2; return; }
108
+ const child = spawn("powershell.exe", trayArgs("tray"), { detached: true, stdio: "ignore", windowsHide: true });
109
+ child.unref();
110
+ console.log("Heny Connect is running in the system tray.");
111
+ }
112
+
113
+ async function install() {
114
+ if (!requireWindows()) return;
115
+ if (!await loadState()) { console.error("Not paired. Pair this computer before installing the tray."); process.exitCode = 2; return; }
116
+ const result = spawnSync("powershell.exe", trayArgs("install"), { encoding: "utf8", windowsHide: true });
117
+ if (result.stdout) process.stdout.write(result.stdout);
118
+ if (result.status !== 0) { if (result.stderr) process.stderr.write(result.stderr); process.exitCode = result.status || 1; }
119
+ }
120
+
121
+ async function uninstall() {
122
+ if (!requireWindows()) return;
123
+ const result = spawnSync("powershell.exe", trayArgs("uninstall"), { encoding: "utf8", windowsHide: true });
124
+ if (result.stdout) process.stdout.write(result.stdout);
125
+ if (result.status !== 0) { if (result.stderr) process.stderr.write(result.stderr); process.exitCode = result.status || 1; }
126
+ }
127
+
128
+ const commands = { pair, run, status, heartbeat: heartbeatOnce, tray, install, uninstall, help: async () => console.log("Commands: pair --code <6 digits> --server <url> [--run] | run [--every 30] [--beats N] | status | tray | install | uninstall") };
129
+ try {
130
+ await (commands[command] || commands.help)();
131
+ } catch (err) {
132
+ console.error(err.message);
133
+ process.exitCode = 1;
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xevy/heny-connect",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Pair a computer with Heny and keep its device presence online.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "bin",
11
+ "windows",
11
12
  "README.md"
12
13
  ],
13
14
  "scripts": {
@@ -0,0 +1,306 @@
1
+ param(
2
+ [Parameter(Mandatory = $true)]
3
+ [string] $NodePath,
4
+
5
+ [Parameter(Mandatory = $true)]
6
+ [string] $CliPath,
7
+
8
+ [string] $IconPath,
9
+ [switch] $Install,
10
+ [switch] $Uninstall
11
+ )
12
+
13
+ $ErrorActionPreference = "Stop"
14
+ $taskName = "HenyConnect"
15
+ $powershellPath = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe"
16
+ if (-not $IconPath) { $IconPath = Join-Path $PSScriptRoot "heny.ico" }
17
+
18
+ function Get-TaskArguments {
19
+ return "-NoLogo -NoProfile -STA -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$PSCommandPath`" -NodePath `"$NodePath`" -CliPath `"$CliPath`" -IconPath `"$IconPath`""
20
+ }
21
+
22
+ function Install-HenyTask {
23
+ param([bool] $StartNow)
24
+
25
+ $existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
26
+ if ($existing) {
27
+ Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
28
+ Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
29
+ }
30
+
31
+ $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().Name
32
+ $action = New-ScheduledTaskAction -Execute $powershellPath -Argument (Get-TaskArguments)
33
+ $trigger = New-ScheduledTaskTrigger -AtLogOn -User $currentUser
34
+ $principal = New-ScheduledTaskPrincipal -UserId $currentUser -LogonType Interactive -RunLevel Limited
35
+ $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew -StartWhenAvailable
36
+ Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null
37
+ if ($StartNow) { Start-ScheduledTask -TaskName $taskName }
38
+ }
39
+
40
+ if ($Uninstall) {
41
+ $existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
42
+ if ($existing) {
43
+ Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
44
+ Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
45
+ }
46
+ Write-Output "Heny Connect start-at-sign-in removed. Quit the tray to end the current session."
47
+ exit 0
48
+ }
49
+
50
+ if ($Install) {
51
+ Install-HenyTask -StartNow $true
52
+ Write-Output "Heny Connect installed. It is running in the system tray and will start at sign-in."
53
+ exit 0
54
+ }
55
+
56
+ $createdNew = $false
57
+ $mutex = [Threading.Mutex]::new($true, "Local\HenyConnectTray", [ref] $createdNew)
58
+ if (-not $createdNew) {
59
+ $mutex.Dispose()
60
+ exit 0
61
+ }
62
+
63
+ Add-Type -AssemblyName System.Windows.Forms
64
+ Add-Type -AssemblyName System.Drawing
65
+ [Windows.Forms.Application]::EnableVisualStyles()
66
+
67
+ $iconAvailable = [Drawing.Icon]::new($IconPath)
68
+ $iconPaused = [Drawing.Icon]::new((Join-Path $PSScriptRoot "heny-paused.ico"))
69
+ $iconError = [Drawing.Icon]::new((Join-Path $PSScriptRoot "heny-error.ico"))
70
+ $notifyIcon = [Windows.Forms.NotifyIcon]::new()
71
+ $notifyIcon.Icon = $iconPaused
72
+ $notifyIcon.Text = "Heny Connect - Connecting"
73
+ $notifyIcon.Visible = $true
74
+
75
+ $menu = [Windows.Forms.ContextMenuStrip]::new()
76
+ $titleItem = [Windows.Forms.ToolStripMenuItem]::new("Heny Connect")
77
+ $titleItem.Enabled = $false
78
+ $titleItem.Font = [Drawing.Font]::new($titleItem.Font, [Drawing.FontStyle]::Bold)
79
+ $statusItem = [Windows.Forms.ToolStripMenuItem]::new("Status: Connecting...")
80
+ $statusItem.Enabled = $false
81
+ $openItem = [Windows.Forms.ToolStripMenuItem]::new("Open Heny")
82
+ $pauseItem = [Windows.Forms.ToolStripMenuItem]::new("Pause")
83
+ $reconnectItem = [Windows.Forms.ToolStripMenuItem]::new("Reconnect now")
84
+ $startItem = [Windows.Forms.ToolStripMenuItem]::new("Start at sign-in")
85
+ $startItem.CheckOnClick = $false
86
+ $startItem.Checked = [bool] (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue)
87
+ $quitItem = [Windows.Forms.ToolStripMenuItem]::new("Quit")
88
+
89
+ $null = $menu.Items.Add($titleItem)
90
+ $null = $menu.Items.Add($statusItem)
91
+ $null = $menu.Items.Add([Windows.Forms.ToolStripSeparator]::new())
92
+ $null = $menu.Items.Add($openItem)
93
+ $null = $menu.Items.Add($pauseItem)
94
+ $null = $menu.Items.Add($reconnectItem)
95
+ $null = $menu.Items.Add($startItem)
96
+ $null = $menu.Items.Add([Windows.Forms.ToolStripSeparator]::new())
97
+ $null = $menu.Items.Add($quitItem)
98
+ $notifyIcon.ContextMenuStrip = $menu
99
+
100
+ $script:paused = $false
101
+ $script:heartbeatProcess = $null
102
+ $script:heartbeatState = $null
103
+ $script:nextHeartbeat = Get-Date
104
+ $script:lastStatus = "connecting"
105
+ $script:exiting = $false
106
+
107
+ function Set-HenyStatus {
108
+ param(
109
+ [ValidateSet("available", "paused", "connecting", "error")]
110
+ [string] $State,
111
+ [string] $Detail
112
+ )
113
+
114
+ $label = switch ($State) {
115
+ "available" { "Available" }
116
+ "paused" { "Paused" }
117
+ "connecting" { "Connecting..." }
118
+ "error" { "Connection error" }
119
+ }
120
+ $statusItem.Text = "Status: $label"
121
+ if ($Detail) { $statusItem.ToolTipText = $Detail }
122
+ $notifyIcon.Text = "Heny Connect - $label"
123
+ $notifyIcon.Icon = switch ($State) {
124
+ "available" { $iconAvailable }
125
+ "error" { $iconError }
126
+ default { $iconPaused }
127
+ }
128
+ $script:lastStatus = $State
129
+ }
130
+
131
+ function Get-HenyUrl {
132
+ try {
133
+ $homePath = if ($env:HENY_CONNECT_HOME) { $env:HENY_CONNECT_HOME } else { [Environment]::GetFolderPath("UserProfile") }
134
+ $statePath = Join-Path $homePath ".heny-connect.json"
135
+ $state = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json
136
+ return ([string] $state.server).TrimEnd("/") + "/desktop"
137
+ } catch {
138
+ return "https://heny.vyte.dev/desktop"
139
+ }
140
+ }
141
+
142
+ function Stop-HeartbeatProcess {
143
+ if (-not $script:heartbeatProcess) { return }
144
+ try {
145
+ if (-not $script:heartbeatProcess.HasExited) {
146
+ $script:heartbeatProcess.Kill()
147
+ $script:heartbeatProcess.WaitForExit(2000) | Out-Null
148
+ }
149
+ $script:heartbeatProcess.Dispose()
150
+ } catch {}
151
+ $script:heartbeatProcess = $null
152
+ $script:heartbeatState = $null
153
+ }
154
+
155
+ function Start-Heartbeat {
156
+ param(
157
+ [ValidateSet("available", "offline")]
158
+ [string] $State = "available"
159
+ )
160
+
161
+ if ($script:heartbeatProcess) { return }
162
+ $detail = if ($State -eq "offline") { "$env:COMPUTERNAME`: paused in tray" } else { "$env:COMPUTERNAME`: browser ready" }
163
+ $startInfo = [Diagnostics.ProcessStartInfo]::new()
164
+ $startInfo.FileName = $NodePath
165
+ $startInfo.Arguments = "`"$CliPath`" heartbeat --state $State --detail `"$detail`""
166
+ $startInfo.UseShellExecute = $false
167
+ $startInfo.CreateNoWindow = $true
168
+ $startInfo.WindowStyle = [Diagnostics.ProcessWindowStyle]::Hidden
169
+ $startInfo.RedirectStandardOutput = $true
170
+ $startInfo.RedirectStandardError = $true
171
+ $process = [Diagnostics.Process]::new()
172
+ $process.StartInfo = $startInfo
173
+ try {
174
+ if (-not $process.Start()) { throw "The heartbeat process did not start." }
175
+ $script:heartbeatProcess = $process
176
+ $script:heartbeatState = $State
177
+ if ($State -eq "available") { Set-HenyStatus -State "connecting" -Detail "Contacting Heny" }
178
+ } catch {
179
+ $process.Dispose()
180
+ Set-HenyStatus -State "error" -Detail $_.Exception.Message
181
+ $script:nextHeartbeat = (Get-Date).AddSeconds(30)
182
+ }
183
+ }
184
+
185
+ function Complete-Heartbeat {
186
+ if (-not $script:heartbeatProcess -or -not $script:heartbeatProcess.HasExited) { return }
187
+ $process = $script:heartbeatProcess
188
+ $state = $script:heartbeatState
189
+ $stdout = $process.StandardOutput.ReadToEnd().Trim()
190
+ $stderr = $process.StandardError.ReadToEnd().Trim()
191
+ $exitCode = $process.ExitCode
192
+ $process.Dispose()
193
+ $script:heartbeatProcess = $null
194
+ $script:heartbeatState = $null
195
+ $script:nextHeartbeat = (Get-Date).AddSeconds(30)
196
+
197
+ if ($exitCode -eq 0) {
198
+ if ($state -eq "offline" -or $script:paused) {
199
+ Set-HenyStatus -State "paused" -Detail "Paused on this computer"
200
+ } else {
201
+ Set-HenyStatus -State "available" -Detail "Connected to Heny"
202
+ }
203
+ return
204
+ }
205
+
206
+ $detail = if ($stderr) { $stderr } elseif ($stdout) { $stdout } else { "Heartbeat exited with code $exitCode" }
207
+ $previousStatus = $script:lastStatus
208
+ Set-HenyStatus -State "error" -Detail $detail
209
+ if ($previousStatus -ne "error") {
210
+ $notifyIcon.ShowBalloonTip(4000, "Heny Connect", $detail, [Windows.Forms.ToolTipIcon]::Warning)
211
+ }
212
+ }
213
+
214
+ function Send-OfflineAndWait {
215
+ Stop-HeartbeatProcess
216
+ $startInfo = [Diagnostics.ProcessStartInfo]::new()
217
+ $startInfo.FileName = $NodePath
218
+ $startInfo.Arguments = "`"$CliPath`" heartbeat --state offline --detail `"$env:COMPUTERNAME`: tray closed`""
219
+ $startInfo.UseShellExecute = $false
220
+ $startInfo.CreateNoWindow = $true
221
+ $startInfo.WindowStyle = [Diagnostics.ProcessWindowStyle]::Hidden
222
+ $process = [Diagnostics.Process]::Start($startInfo)
223
+ if ($process) {
224
+ $process.WaitForExit(10000) | Out-Null
225
+ if (-not $process.HasExited) { $process.Kill() }
226
+ $process.Dispose()
227
+ }
228
+ }
229
+
230
+ $openHeny = {
231
+ Start-Process (Get-HenyUrl)
232
+ }
233
+ $openItem.Add_Click($openHeny)
234
+ $notifyIcon.Add_DoubleClick($openHeny)
235
+
236
+ $pauseItem.Add_Click({
237
+ if ($script:paused) {
238
+ $script:paused = $false
239
+ $pauseItem.Text = "Pause"
240
+ $script:nextHeartbeat = Get-Date
241
+ Set-HenyStatus -State "connecting" -Detail "Resuming"
242
+ } else {
243
+ $script:paused = $true
244
+ $pauseItem.Text = "Resume"
245
+ Stop-HeartbeatProcess
246
+ Start-Heartbeat -State "offline"
247
+ }
248
+ })
249
+
250
+ $reconnectItem.Add_Click({
251
+ $script:paused = $false
252
+ $pauseItem.Text = "Pause"
253
+ Stop-HeartbeatProcess
254
+ $script:nextHeartbeat = Get-Date
255
+ Set-HenyStatus -State "connecting" -Detail "Reconnecting"
256
+ })
257
+
258
+ $startItem.Add_Click({
259
+ try {
260
+ if ($startItem.Checked) {
261
+ Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
262
+ $startItem.Checked = $false
263
+ $notifyIcon.ShowBalloonTip(2500, "Heny Connect", "Start at sign-in is off.", [Windows.Forms.ToolTipIcon]::Info)
264
+ } else {
265
+ Install-HenyTask -StartNow $false
266
+ $startItem.Checked = $true
267
+ $notifyIcon.ShowBalloonTip(2500, "Heny Connect", "Start at sign-in is on.", [Windows.Forms.ToolTipIcon]::Info)
268
+ }
269
+ } catch {
270
+ Set-HenyStatus -State "error" -Detail $_.Exception.Message
271
+ }
272
+ })
273
+
274
+ $quitItem.Add_Click({
275
+ $script:exiting = $true
276
+ $timer.Stop()
277
+ Send-OfflineAndWait
278
+ $notifyIcon.Visible = $false
279
+ [Windows.Forms.Application]::ExitThread()
280
+ })
281
+
282
+ $timer = [Windows.Forms.Timer]::new()
283
+ $timer.Interval = 1000
284
+ $timer.Add_Tick({
285
+ Complete-Heartbeat
286
+ if (-not $script:paused -and -not $script:heartbeatProcess -and (Get-Date) -ge $script:nextHeartbeat) {
287
+ Start-Heartbeat -State "available"
288
+ }
289
+ })
290
+ $timer.Start()
291
+ Start-Heartbeat -State "available"
292
+
293
+ try {
294
+ [Windows.Forms.Application]::Run()
295
+ } finally {
296
+ $timer.Stop()
297
+ Stop-HeartbeatProcess
298
+ $notifyIcon.Visible = $false
299
+ $notifyIcon.Dispose()
300
+ $menu.Dispose()
301
+ $iconAvailable.Dispose()
302
+ $iconPaused.Dispose()
303
+ $iconError.Dispose()
304
+ if ($createdNew) { $mutex.ReleaseMutex() }
305
+ $mutex.Dispose()
306
+ }
Binary file
Binary file
Binary file