livedesk 0.1.446 → 0.1.447

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.
@@ -30,6 +30,14 @@ function windowsTicksForUnixMilliseconds(milliseconds) {
30
30
  return BigInt(Math.trunc(Number(milliseconds) || 0)) * 10_000n + 621_355_968_000_000_000n;
31
31
  }
32
32
 
33
+ function unixMillisecondsForWindowsTicks(ticks) {
34
+ try {
35
+ return Number((BigInt(ticks) - 621_355_968_000_000_000n) / 10_000n);
36
+ } catch {
37
+ return 0;
38
+ }
39
+ }
40
+
33
41
  function windowsRecordIsWithinLifetime(record, spawnedAtMs, exitedAtMs = null) {
34
42
  if (!record?.startOrder) return false;
35
43
  try {
@@ -72,7 +80,7 @@ export async function queryWindowsProcessTable({
72
80
  } = {}) {
73
81
  const script = [
74
82
  '$ErrorActionPreference = "Stop";',
75
- '@(Get-CimInstance Win32_Process | ForEach-Object {',
83
+ '@(Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CreationDate | ForEach-Object {',
76
84
  ' $creation = [string]$_.CreationDate;',
77
85
  ' $ticks = if ($_.CreationDate) { [string]$_.CreationDate.ToUniversalTime().Ticks } else { "" };',
78
86
  ' [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId; CreationDate = $creation; CreationUtcTicks = $ticks }',
@@ -102,31 +110,42 @@ export async function queryWindowsProcessTable({
102
110
  }
103
111
 
104
112
  /**
105
- * Rechecks CreationDate inside the same PowerShell process immediately before
106
- * taskkill. PID reuse therefore becomes a harmless identity mismatch instead
107
- * of terminating the replacement process.
113
+ * Rechecks CreationDate and force-stops the single PID inside the same
114
+ * PowerShell process. PID reuse therefore becomes a harmless identity mismatch
115
+ * instead of terminating the replacement process.
108
116
  */
109
- export async function terminateExactWindowsProcessTree(record, {
110
- includeTree = true,
117
+ export async function terminateExactWindowsProcessTree(recordOrRecords, {
111
118
  execFileImpl = execFile
112
119
  } = {}) {
113
- const target = normalizeWindowsProcessRecord(record);
114
- if (!target) {
115
- return { ok: false, error: new Error('An immutable Windows process record is required.') };
120
+ const targets = (Array.isArray(recordOrRecords) ? recordOrRecords : [recordOrRecords])
121
+ .map(normalizeWindowsProcessRecord)
122
+ .filter(Boolean);
123
+ if (targets.length === 0) {
124
+ return { ok: false, error: new Error('At least one immutable Windows process record is required.') };
116
125
  }
117
- const expectedStartTicks = target.startOrder;
118
- const treeArgument = includeTree ? ', "/T"' : '';
126
+ const targetsJson = JSON.stringify(targets.map(target => ({
127
+ pid: target.pid,
128
+ startOrder: target.startOrder
129
+ }))).replaceAll("'", "''");
119
130
  const script = [
120
131
  '$ErrorActionPreference = "Stop";',
121
- `$targetPid = ${target.pid};`,
122
- `$expectedStartTicks = '${expectedStartTicks}';`,
123
- '$target = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -ErrorAction SilentlyContinue | Select-Object -First 1;',
124
- 'if (-not $target) { exit 0 };',
125
- '$targetStartTicks = if ($target.CreationDate) { [string]$target.CreationDate.ToUniversalTime().Ticks } else { "" };',
126
- 'if ($targetStartTicks -ne $expectedStartTicks) { exit 0 };',
127
- `$arguments = @("/PID", [string]$targetPid${treeArgument}, "/F");`,
128
- '$taskkill = Start-Process -FilePath "taskkill.exe" -ArgumentList $arguments -NoNewWindow -Wait -PassThru;',
129
- 'exit $taskkill.ExitCode'
132
+ `$targets = ConvertFrom-Json -InputObject '${targetsJson}';`,
133
+ '$failures = [System.Collections.Generic.List[string]]::new();',
134
+ 'foreach ($item in $targets) {',
135
+ ' $targetPid = [int]$item.pid;',
136
+ ' $expectedStartTicks = [string]$item.startOrder;',
137
+ ' try {',
138
+ ' $target = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -Property ProcessId,CreationDate -ErrorAction SilentlyContinue | Select-Object -First 1;',
139
+ ' if (-not $target) { continue };',
140
+ ' $targetStartTicks = if ($target.CreationDate) { [string]$target.CreationDate.ToUniversalTime().Ticks } else { "" };',
141
+ ' if ($targetStartTicks -ne $expectedStartTicks) { continue };',
142
+ ' Stop-Process -Id $targetPid -Force -ErrorAction Stop;',
143
+ ' } catch {',
144
+ ' $failures.Add("pid=$targetPid $($_.Exception.Message)");',
145
+ ' }',
146
+ '}',
147
+ 'if ($failures.Count -gt 0) { [Console]::Error.WriteLine(($failures -join "; ")); exit 1 };',
148
+ 'exit 0;'
130
149
  ].join(' ');
131
150
  return runExecFile(
132
151
  'powershell.exe',
@@ -148,7 +167,11 @@ export function createWindowsProcessTreeTracker(child, {
148
167
  snapshotIntervalMs = DEFAULT_WINDOWS_TREE_SNAPSHOT_MS,
149
168
  spawnedAtMs = Date.now(),
150
169
  setIntervalImpl = setInterval,
151
- clearIntervalImpl = clearInterval
170
+ clearIntervalImpl = clearInterval,
171
+ publishTrackedRecords = null,
172
+ reportPublisherError = error => console.warn(
173
+ `[LiveDesk Client] Windows owned-process manifest warning: ${error?.message || error}`
174
+ )
152
175
  } = {}) {
153
176
  const rootPid = Number(child?.pid || 0);
154
177
  const enabled = platform === 'win32' && Number.isInteger(rootPid) && rootPid > 1;
@@ -164,19 +187,33 @@ export function createWindowsProcessTreeTracker(child, {
164
187
  let lastSnapshotHadRootPid = false;
165
188
  let successfulSnapshotAfterExit = false;
166
189
  let ambiguousRootReuse = false;
190
+ let lastPublisherError = null;
167
191
 
168
192
  const mergeSnapshot = snapshot => {
169
193
  const records = (Array.isArray(snapshot) ? snapshot : [])
170
194
  .map(normalizeWindowsProcessRecord)
171
195
  .filter(Boolean);
172
196
  lastSnapshot = records;
173
- if (rootExitedAtMs !== null) successfulSnapshotAfterExit = true;
174
197
  const currentByPid = new Map(records.map(record => [record.pid, record]));
175
198
  const currentRoot = currentByPid.get(rootPid) || null;
176
199
  lastSnapshotHadRootPid = Boolean(currentRoot);
177
- if (rootRecord && currentRoot && !sameWindowsProcess(currentRoot, rootRecord)) {
200
+ const rootWasReused = Boolean(
201
+ rootRecord && currentRoot && !sameWindowsProcess(currentRoot, rootRecord)
202
+ );
203
+ if (rootWasReused) {
178
204
  ambiguousRootReuse = true;
179
205
  }
206
+ if (rootRecord && rootExitedAtMs === null && (!currentRoot || rootWasReused)) {
207
+ // The exit event can trail the first CIM snapshot that proves the
208
+ // exact root is gone. Use that proof immediately so a surviving
209
+ // child first observed in this same snapshot is still owned.
210
+ // For PID reuse, cap the old lifetime just before the replacement
211
+ // CreationDate so replacement-root children remain excluded.
212
+ rootExitedAtMs = rootWasReused
213
+ ? unixMillisecondsForWindowsTicks(currentRoot.startOrder) - 1
214
+ : Date.now();
215
+ }
216
+ if (rootExitedAtMs !== null) successfulSnapshotAfterExit = true;
180
217
  if (!rootRecord
181
218
  && currentRoot
182
219
  && windowsRecordIsWithinLifetime(currentRoot, spawnedAtMs, rootExitedAtMs)) {
@@ -202,9 +239,11 @@ export function createWindowsProcessTreeTracker(child, {
202
239
  }
203
240
  }
204
241
 
205
- // If the root PID is absent, Windows retains ParentProcessId on its
206
- // surviving direct children. If the PID has already been reused, do
207
- // not infer any new lineage through that ambiguous numeric PID.
242
+ // Windows retains ParentProcessId on surviving direct children after
243
+ // the root exits. A reused root PID is also safe to bridge only for a
244
+ // direct child whose immutable creation time is inside the original
245
+ // root lifetime; children created by the replacement root are rejected
246
+ // by windowsRecordIsWithinLifetime.
208
247
  const rootAbsent = !currentRoot;
209
248
  let changed = true;
210
249
  while (changed) {
@@ -215,7 +254,7 @@ export function createWindowsProcessTreeTracker(child, {
215
254
  let parent = exactCurrentByPid.get(record.parentPid) || null;
216
255
  if (!parent
217
256
  && rootExitedAtMs !== null
218
- && rootAbsent
257
+ && (rootAbsent || rootWasReused)
219
258
  && record.parentPid === rootPid) {
220
259
  parent = rootRecord || { pid: rootPid, depth: 0 };
221
260
  }
@@ -233,10 +272,34 @@ export function createWindowsProcessTreeTracker(child, {
233
272
  if (inFlight) return inFlight;
234
273
  inFlight = Promise.resolve()
235
274
  .then(() => queryProcessTableImpl())
236
- .then(snapshot => {
275
+ .then(async snapshot => {
237
276
  mergeSnapshot(snapshot);
238
277
  lastError = null;
239
- return { ok: true, records: lastSnapshot };
278
+ if (typeof publishTrackedRecords === 'function') {
279
+ try {
280
+ await publishTrackedRecords(
281
+ [...tracked.values()].map(record => ({ ...record })),
282
+ {
283
+ rootPid,
284
+ rootRecord: rootRecord ? { ...rootRecord } : null,
285
+ snapshot: lastSnapshot.map(record => ({ ...record })),
286
+ terminal: stopped
287
+ }
288
+ );
289
+ lastPublisherError = null;
290
+ } catch (error) {
291
+ // Manifest publication must never break the in-memory
292
+ // cleanup proof. Keep tracking, surface diagnostics,
293
+ // and retry on the next cadence/terminal refresh.
294
+ lastPublisherError = error;
295
+ try { reportPublisherError(error); } catch { /* diagnostics cannot own lifecycle */ }
296
+ }
297
+ }
298
+ return {
299
+ ok: true,
300
+ records: lastSnapshot,
301
+ publisherError: lastPublisherError
302
+ };
240
303
  })
241
304
  .catch(error => {
242
305
  lastError = error;
@@ -285,7 +348,8 @@ export function createWindowsProcessTreeTracker(child, {
285
348
  .filter(record => sameWindowsProcess(currentByPid.get(record.pid), record))
286
349
  .map(record => ({ ...record }));
287
350
  },
288
- getLastError: () => lastError
351
+ getLastError: () => lastError,
352
+ getLastPublisherError: () => lastPublisherError
289
353
  };
290
354
  }
291
355
 
@@ -345,17 +409,18 @@ export async function drainOwnedWindowsAgentTree(child, {
345
409
  } else {
346
410
  const remaining = tracker.getCurrentExactRecords();
347
411
  if (remaining.length === 0
348
- && tracker.hasSafeFinalSnapshot?.()
349
- && !tracker.hasAmbiguousRootReuse?.()) {
412
+ && tracker.hasSafeFinalSnapshot?.()) {
350
413
  return { ok: true, remaining: [] };
351
414
  }
352
415
  const ordered = [...remaining].sort((left, right) => (
353
- Number(left.depth || 0) - Number(right.depth || 0)
416
+ Number(right.depth || 0) - Number(left.depth || 0)
354
417
  ));
355
- for (const record of ordered) {
356
- const termination = await terminateExactTreeImpl(record, { includeTree: true });
357
- if (termination?.ok === false) lastError = termination.error || lastError;
358
- }
418
+ // One PowerShell invocation receives the descendant-first
419
+ // immutable snapshot, then rechecks every PID CreationDate
420
+ // immediately before its own Stop-Process. No taskkill /T
421
+ // traversal can follow an unverified stale PPID edge.
422
+ const termination = await terminateExactTreeImpl(ordered, { includeTree: false });
423
+ if (termination?.ok === false) lastError = termination.error || lastError;
359
424
  }
360
425
  if (attempt < attempts) {
361
426
  await waitImpl(Math.max(25, Number(retryMs) || 100));
@@ -366,8 +431,7 @@ export async function drainOwnedWindowsAgentTree(child, {
366
431
  const remaining = finalRefresh.ok ? tracker.getCurrentExactRecords() : tracker.getTrackedRecords();
367
432
  if (finalRefresh.ok
368
433
  && remaining.length === 0
369
- && tracker.hasSafeFinalSnapshot?.()
370
- && !tracker.hasAmbiguousRootReuse?.()) {
434
+ && tracker.hasSafeFinalSnapshot?.()) {
371
435
  return { ok: true, remaining: [] };
372
436
  }
373
437
  const error = finalRefresh.error || lastError || new Error(
@@ -717,9 +781,18 @@ export function installAgentTerminationHandlers({
717
781
  hostProcess.on(signal, handler);
718
782
  }
719
783
 
720
- return () => {
784
+ const dispose = () => {
721
785
  for (const [signal, handler] of handlers) {
722
786
  hostProcess.removeListener(signal, handler);
723
787
  }
724
788
  };
789
+ // Windows process.kill(pid, 'SIGTERM') can terminate a process without
790
+ // dispatching its JavaScript SIGTERM listener. Replacement startup must
791
+ // enter this exact cleanup state machine directly instead of simulating an
792
+ // operating-system signal.
793
+ dispose.requestTermination = (signal = 'SIGTERM') => {
794
+ const normalizedSignal = signal === 'SIGINT' ? 'SIGINT' : 'SIGTERM';
795
+ handlers.get(normalizedSignal)?.();
796
+ };
797
+ return dispose;
725
798
  }
@@ -0,0 +1,235 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import {
3
+ chmodSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ unlinkSync,
8
+ writeFileSync
9
+ } from 'node:fs';
10
+ import { dirname, join } from 'node:path';
11
+ import os from 'node:os';
12
+
13
+ export const WINDOWS_OWNED_PROCESS_MANIFEST_VERSION = 1;
14
+ export const DEFAULT_WINDOWS_OWNED_PROCESS_MANIFEST_MAX_AGE_MS = 30_000;
15
+
16
+ const WINDOWS_OWNED_PROCESS_MANIFEST_FILE_NAME = 'client-owned-windows-processes.json';
17
+ const MAX_FUTURE_CLOCK_SKEW_MS = 5_000;
18
+
19
+ function resultError(message) {
20
+ return new Error(message);
21
+ }
22
+
23
+ function resolveNow(now) {
24
+ const value = typeof now === 'function' ? now() : now;
25
+ const milliseconds = value === undefined ? Date.now() : Number(value);
26
+ return Number.isFinite(milliseconds) ? milliseconds : NaN;
27
+ }
28
+
29
+ function normalizeOwner(value) {
30
+ const owner = {
31
+ pid: Number(value?.pid || 0),
32
+ ownerToken: String(value?.ownerToken || '').trim(),
33
+ ownerInstanceMarker: String(value?.ownerInstanceMarker || '').trim(),
34
+ ownerStartOrder: String(value?.ownerStartOrder || '').trim()
35
+ };
36
+ if (!Number.isInteger(owner.pid)
37
+ || owner.pid <= 1
38
+ || !/^[a-f0-9]{32}$/i.test(owner.ownerToken)
39
+ || !owner.ownerInstanceMarker.startsWith(`${owner.pid}:`)
40
+ || !/^\d+$/.test(owner.ownerStartOrder)) {
41
+ return null;
42
+ }
43
+ return owner;
44
+ }
45
+
46
+ function ownersMatch(leftValue, rightValue) {
47
+ const left = normalizeOwner(leftValue);
48
+ const right = normalizeOwner(rightValue);
49
+ return !!left
50
+ && !!right
51
+ && left.pid === right.pid
52
+ && left.ownerToken === right.ownerToken
53
+ && left.ownerInstanceMarker === right.ownerInstanceMarker
54
+ && left.ownerStartOrder === right.ownerStartOrder;
55
+ }
56
+
57
+ function normalizeRecord(value) {
58
+ const record = {
59
+ pid: Number(value?.pid ?? value?.ProcessId ?? 0),
60
+ parentPid: Number(value?.parentPid ?? value?.ParentProcessId ?? 0),
61
+ startMarker: String(value?.startMarker ?? value?.CreationDate ?? '').trim(),
62
+ startOrder: String(value?.startOrder ?? value?.CreationUtcTicks ?? '').trim(),
63
+ depth: Math.max(0, Math.trunc(Number(value?.depth || 0)))
64
+ };
65
+ if (!Number.isInteger(record.pid)
66
+ || record.pid <= 1
67
+ || !Number.isInteger(record.parentPid)
68
+ || record.parentPid < 0
69
+ || !record.startMarker
70
+ || !/^\d+$/.test(record.startOrder)
71
+ || !Number.isFinite(record.depth)) {
72
+ return null;
73
+ }
74
+ return record;
75
+ }
76
+
77
+ function normalizeRecords(values) {
78
+ if (!Array.isArray(values)) return null;
79
+ const records = values.map(normalizeRecord);
80
+ if (records.some(record => !record)) return null;
81
+ const unique = new Map();
82
+ for (const record of records) {
83
+ unique.set(`${record.pid}:${record.startOrder}`, record);
84
+ }
85
+ return [...unique.values()];
86
+ }
87
+
88
+ function writeJsonAtomic(path, value) {
89
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
90
+ const temporaryPath = `${path}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
91
+ let published = false;
92
+ try {
93
+ writeFileSync(temporaryPath, JSON.stringify(value, null, 2), {
94
+ encoding: 'utf8',
95
+ mode: 0o600,
96
+ flag: 'wx'
97
+ });
98
+ renameSync(temporaryPath, path);
99
+ // Existing state directories can have been created by older versions;
100
+ // enforce the owner-only contract after every atomic replacement too.
101
+ try { chmodSync(path, 0o600); } catch { /* Windows ACLs remain authoritative */ }
102
+ published = true;
103
+ } finally {
104
+ if (!published) {
105
+ try { unlinkSync(temporaryPath); } catch { /* temporary file was never published */ }
106
+ }
107
+ }
108
+ }
109
+
110
+ export function getWindowsOwnedProcessManifestPath(
111
+ stateDir = join(os.homedir(), '.livedesk')
112
+ ) {
113
+ return join(stateDir, WINDOWS_OWNED_PROCESS_MANIFEST_FILE_NAME);
114
+ }
115
+
116
+ /**
117
+ * Publishes one immutable launcher generation and all exact Agent records.
118
+ * Every record retains PID + full-precision CreationDate ticks so a later
119
+ * launcher can harmlessly ignore a PID that has since been reused.
120
+ */
121
+ export function writeWindowsOwnedProcessManifest({
122
+ stateDir,
123
+ owner,
124
+ records,
125
+ now
126
+ } = {}) {
127
+ const exactOwner = normalizeOwner(owner);
128
+ const exactRecords = normalizeRecords(records);
129
+ const nowMs = resolveNow(now);
130
+ if (!exactOwner) {
131
+ return {
132
+ ok: false,
133
+ error: resultError('An exact Windows LiveDesk launcher owner is required.')
134
+ };
135
+ }
136
+ if (!exactRecords) {
137
+ return {
138
+ ok: false,
139
+ error: resultError('Every Windows LiveDesk process record requires exact PID and CreationDate identity.')
140
+ };
141
+ }
142
+ if (!Number.isFinite(nowMs)) {
143
+ return { ok: false, error: resultError('A valid manifest publication time is required.') };
144
+ }
145
+ const path = getWindowsOwnedProcessManifestPath(stateDir);
146
+ const updatedAt = new Date(nowMs).toISOString();
147
+ try {
148
+ writeJsonAtomic(path, {
149
+ protocolVersion: WINDOWS_OWNED_PROCESS_MANIFEST_VERSION,
150
+ owner: exactOwner,
151
+ updatedAt,
152
+ records: exactRecords
153
+ });
154
+ return { ok: true, path, records: exactRecords, updatedAt };
155
+ } catch (error) {
156
+ return { ok: false, path, records: [], error };
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Reads only a fresh manifest belonging to the exact runtime-lock generation.
162
+ * Owner mismatch, malformed identity, expiry, and PID reuse all fail closed.
163
+ */
164
+ export function readWindowsOwnedProcessManifest({
165
+ stateDir,
166
+ owner,
167
+ maxAgeMs = DEFAULT_WINDOWS_OWNED_PROCESS_MANIFEST_MAX_AGE_MS,
168
+ now
169
+ } = {}) {
170
+ const exactOwner = normalizeOwner(owner);
171
+ const nowMs = resolveNow(now);
172
+ const maximumAge = Number(maxAgeMs);
173
+ const path = getWindowsOwnedProcessManifestPath(stateDir);
174
+ if (!exactOwner) {
175
+ return {
176
+ ok: false,
177
+ records: [],
178
+ error: resultError('An exact Windows LiveDesk launcher owner is required.')
179
+ };
180
+ }
181
+ if (!Number.isFinite(nowMs)
182
+ || !Number.isFinite(maximumAge)
183
+ || maximumAge < 0) {
184
+ return {
185
+ ok: false,
186
+ records: [],
187
+ error: resultError('A valid manifest time and maximum age are required.')
188
+ };
189
+ }
190
+
191
+ let parsed;
192
+ try {
193
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
194
+ } catch (error) {
195
+ return { ok: false, records: [], error };
196
+ }
197
+ const updatedAt = String(parsed?.updatedAt || '');
198
+ const updatedAtMs = Date.parse(updatedAt);
199
+ if (parsed?.protocolVersion !== WINDOWS_OWNED_PROCESS_MANIFEST_VERSION) {
200
+ return {
201
+ ok: false,
202
+ records: [],
203
+ updatedAt,
204
+ error: resultError('Unsupported Windows LiveDesk process-manifest version.')
205
+ };
206
+ }
207
+ if (!ownersMatch(parsed?.owner, exactOwner)) {
208
+ return {
209
+ ok: false,
210
+ records: [],
211
+ updatedAt,
212
+ error: resultError('Windows LiveDesk process manifest belongs to another launcher generation.')
213
+ };
214
+ }
215
+ if (!Number.isFinite(updatedAtMs)
216
+ || updatedAtMs > nowMs + MAX_FUTURE_CLOCK_SKEW_MS
217
+ || nowMs - updatedAtMs > maximumAge) {
218
+ return {
219
+ ok: false,
220
+ records: [],
221
+ updatedAt,
222
+ error: resultError('Windows LiveDesk process manifest is stale or has an invalid timestamp.')
223
+ };
224
+ }
225
+ const exactRecords = normalizeRecords(parsed?.records);
226
+ if (!exactRecords) {
227
+ return {
228
+ ok: false,
229
+ records: [],
230
+ updatedAt,
231
+ error: resultError('Windows LiveDesk process manifest contains an invalid process identity.')
232
+ };
233
+ }
234
+ return { ok: true, records: exactRecords, updatedAt };
235
+ }
package/electron/main.mjs CHANGED
@@ -1,12 +1,16 @@
1
1
  import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, nativeImage, safeStorage, shell, Tray } from 'electron';
2
2
  import { createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- import { join, resolve } from 'node:path';
5
- import { execFile, spawn } from 'node:child_process';
3
+ import { homedir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
6
  import { createProductUpdateManager } from './update-manager.mjs';
7
7
  import { createPkceAuthorizationUrl, exchangePkceCode, fetchSupabaseUser, parsePkceCallbackUrl, refreshSupabaseSession } from './oauth-pkce.mjs';
8
8
  import { getRuntimeAuthEnvironment, resolveDesktopAuthConfig, resolveDesktopBuildFlavor } from './auth-config.mjs';
9
9
  import { resolveDeviceRole } from '../bootstrap/device-role.js';
10
+ import {
11
+ registerOwnedWindowsChild,
12
+ terminateExactRegisteredWindowsTreeUntilStopped
13
+ } from '../runtime-core/src/windows-owned-process-tree.js';
10
14
 
11
15
  const APP_NAME = 'LiveDesk Desktop';
12
16
  const LOCAL_RUNTIME_PORT = Number(process.env.LIVEDESK_LOCAL_RUNTIME_PORT || 5179);
@@ -234,14 +238,17 @@ function waitForChildExit(child, timeoutMs = 2500) {
234
238
  });
235
239
  }
236
240
 
237
- async function killRuntimeTree(child) {
238
- if (!child || child.exitCode !== null || child.signalCode || !child.pid) return;
239
- if (process.platform === 'win32') {
240
- await new Promise(resolveKill => {
241
- execFile('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true }, () => resolveKill());
242
- });
243
- } else {
244
- try { child.kill('SIGTERM'); } catch { /* already stopped */ }
241
+ async function killRuntimeTree(child) {
242
+ if (!child?.pid) return;
243
+ if (process.platform === 'win32') {
244
+ await terminateExactRegisteredWindowsTreeUntilStopped(child, {
245
+ onRetry: (error, attempt) => {
246
+ log(`runtime exact Windows cleanup retry=${attempt} error=${error?.message || error}`);
247
+ }
248
+ });
249
+ } else {
250
+ if (child.exitCode !== null || child.signalCode) return;
251
+ try { child.kill('SIGTERM'); } catch { /* already stopped */ }
245
252
  await waitForChildExit(child, 1200);
246
253
  if (child.exitCode === null && !child.signalCode) {
247
254
  try { child.kill('SIGKILL'); } catch { /* already stopped */ }
@@ -272,20 +279,24 @@ function startRuntime(options = {}) {
272
279
  ...(process.versions.electron && !process.env.LIVEDESK_RUNTIME_NODE ? { ELECTRON_RUN_AS_NODE: '1' } : {})
273
280
  },
274
281
  windowsHide: true,
275
- stdio: ['ignore', 'pipe', 'pipe']
276
- });
277
- runtimeChild = child;
278
- child.stdout?.on('data', chunk => log(chunk));
282
+ stdio: ['ignore', 'pipe', 'pipe']
283
+ });
284
+ child.livedeskWindowsSpawnedAtMs = runtimeLaunchStartedAt;
285
+ runtimeChild = child;
286
+ void registerOwnedWindowsChild(child);
287
+ child.stdout?.on('data', chunk => log(chunk));
279
288
  child.stderr?.on('data', chunk => log(chunk));
280
289
  child.once('error', error => log(`runtime spawn error: ${error.message}`));
281
- child.once('exit', (code, signal) => {
282
- log(`runtime exit code=${code ?? 'null'} signal=${signal || 'none'}`);
283
- if (runtimeChild === child) runtimeChild = null;
284
- const intentionalStop = runtimeIntentionalStop;
285
- runtimeIntentionalStop = false;
286
- if (!isQuitting && !intentionalStop) {
287
- updateTray('Runtime restarting');
288
- const now = Date.now();
290
+ child.once('exit', (code, signal) => {
291
+ log(`runtime exit code=${code ?? 'null'} signal=${signal || 'none'}`);
292
+ if (runtimeChild === child) runtimeChild = null;
293
+ const intentionalStop = runtimeIntentionalStop;
294
+ runtimeIntentionalStop = false;
295
+ const continueAfterExactDrain = async () => {
296
+ if (process.platform === 'win32') await killRuntimeTree(child);
297
+ if (isQuitting || intentionalStop) return;
298
+ updateTray('Runtime restarting');
299
+ const now = Date.now();
289
300
  if (!runtimeRestartWindowStartedAt || now - runtimeRestartWindowStartedAt > 30_000) {
290
301
  runtimeRestartWindowStartedAt = now;
291
302
  runtimeRestartCount = 0;
@@ -297,12 +308,13 @@ function startRuntime(options = {}) {
297
308
  startRuntime();
298
309
  void waitForRuntime().then(ready => { if (ready) mainWindow?.loadURL(LOCAL_RUNTIME_URL); });
299
310
  }, 1200);
300
- } else {
301
- updateTray('Runtime stopped');
302
- log(`runtime supervisor stopped after ${runtimeRestartCount - 1} retries`);
303
- }
304
- }
305
- });
311
+ } else {
312
+ updateTray('Runtime stopped');
313
+ log(`runtime supervisor stopped after ${runtimeRestartCount - 1} retries`);
314
+ }
315
+ };
316
+ void continueAfterExactDrain();
317
+ });
306
318
  log(`runtime started pid=${child.pid}`);
307
319
  return child;
308
320
  }
@@ -354,11 +366,11 @@ async function stopRuntime() {
354
366
  }
355
367
  const child = runtimeChild;
356
368
  if (!child) return;
357
- runtimeIntentionalStop = true;
358
- await requestRuntimeShutdown();
359
- const exitedGracefully = await waitForChildExit(child);
360
- if (!exitedGracefully) await killRuntimeTree(child);
361
- runtimeChild = null;
369
+ runtimeIntentionalStop = true;
370
+ await requestRuntimeShutdown();
371
+ const exitedGracefully = await waitForChildExit(child);
372
+ if (process.platform === 'win32' || !exitedGracefully) await killRuntimeTree(child);
373
+ runtimeChild = null;
362
374
  }
363
375
 
364
376
  async function restartRuntimeProcess() {
@@ -779,9 +791,14 @@ if (!app.requestSingleInstanceLock()) {
779
791
  event.preventDefault();
780
792
  quitSequenceStarted = true;
781
793
  isQuitting = true;
782
- void stopRuntime().finally(() => {
783
- logStream?.end();
784
- app.quit();
785
- });
794
+ void stopRuntime().then(() => {
795
+ logStream?.end();
796
+ app.quit();
797
+ }).catch(error => {
798
+ isQuitting = false;
799
+ quitSequenceStarted = false;
800
+ updateTray('Runtime cleanup blocked');
801
+ log(`runtime cleanup blocked application quit: ${error?.message || error}`);
802
+ });
786
803
  });
787
804
  }
package/hub/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "LiveDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",