@telepath-computer/television-desktop 0.1.206 → 0.1.208

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
@@ -1,27 +1,33 @@
1
1
  # @telepath-computer/television-desktop
2
2
 
3
- Electron desktop wrapper for [Television](https://www.npmjs.com/package/@telepath-computer/television).
3
+ The Television desktop application is an Electron client for a running [Television](https://www.npmjs.com/package/@telepath-computer/television) server. It renders URL artifacts as full embedded pages in addition to providing a native application window.
4
4
 
5
- ## Install
5
+ ## Requirements
6
+
7
+ The package requires Node.js `>=22.12.0`. Its baseline platforms are Linux, macOS, and Windows. On macOS, Television supports Apple Silicon running macOS 12 or later.
8
+
9
+ ## Install and run
6
10
 
7
11
  ```sh
8
- npm i -g @telepath-computer/television-desktop
12
+ npm i -g @telepath-computer/television-desktop@latest
13
+ tv-desktop
9
14
  ```
10
15
 
11
- ## Run
16
+ The connect window asks for the Television server URL and, when the server uses authentication, its token.
17
+
18
+ The first launch after every install or upgrade prepares Electron's platform runtime and prints a message before it begins. A network download may be required. On macOS, this launch also prepares the Television application bundle so the Dock and application menu use the Television name and icon. Later launches from the same installation reuse the prepared runtime.
19
+
20
+ Electron keeps downloaded macOS archives by version under `~/Library/Caches/electron`. Television does not prune this cache. It is safe to delete while Television is stopped; the next setup downloads the archive again.
21
+
22
+ ## Recovery
23
+
24
+ If the launcher reports an incomplete or mismatched runtime, reinstall the package and launch again:
12
25
 
13
26
  ```sh
27
+ npm i -g @telepath-computer/television-desktop@latest
14
28
  tv-desktop
15
29
  ```
16
30
 
17
- A connect window opens; point it at a running Television server. Enter the
18
- server URL plus token when the server runs with `--auth`; leave the token blank
19
- for no-auth servers.
20
-
21
- ## macOS notes
31
+ The reinstall replaces package-owned runtime files without deleting saved Television connections or other user data. A download failure instead means the network problem should be corrected before retrying `tv-desktop`.
22
32
 
23
- The first launch renames the bundled `Electron.app` to `Television.app`
24
- (bundle directory + inner executable + `Info.plist` + bundle icon). This
25
- is what gives the Dock and menu bar "Television" instead of "Electron".
26
- First launch is a few seconds slower; every launch after takes the fast
27
- path.
33
+ The authoritative product behavior is [the desktop application spec](https://github.com/telepath-computer/television/blob/main/specs/product/desktop-app.md); runtime, launcher, branding, and test-harness contracts are mapped by [the desktop architecture spec](https://github.com/telepath-computer/television/blob/main/specs/arch/desktop/index.md).
@@ -1,40 +1,48 @@
1
1
  #!/usr/bin/env node
2
+
2
3
  const { spawn, spawnSync } = require("node:child_process");
4
+ const { createRequire } = require("node:module");
3
5
  const path = require("node:path");
4
- const fs = require("node:fs");
6
+ const { runDesktopLauncher } = require("../scripts/launcher-core.cjs");
5
7
 
6
- const appDir = path.join(__dirname, "..");
8
+ const desktopPackageDirectory = path.join(__dirname, "..");
9
+ const requireFromDesktop = createRequire(path.join(desktopPackageDirectory, "package.json"));
7
10
 
8
- function resolveExecutable() {
9
- const electronPkgDir = path.dirname(require.resolve("electron/package.json"));
10
- const distDir = path.join(electronPkgDir, "dist");
11
- if (process.platform === "darwin") {
12
- const renamed = path.join(distDir, "Television.app", "Contents", "MacOS", "Television");
13
- if (fs.existsSync(renamed)) return renamed;
14
- // npm runs our package's postinstall before electron's postinstall has
15
- // downloaded the Electron.app, so we can't rely on a postinstall hook.
16
- // Rename lazily on first launch (idempotent); subsequent launches take
17
- // the fast path above.
18
- const upstream = path.join(distDir, "Electron.app");
19
- if (fs.existsSync(upstream)) {
20
- spawnSync(process.execPath, [path.join(appDir, "scripts", "rename-bundle.cjs")], {
21
- stdio: "inherit",
22
- });
23
- if (fs.existsSync(renamed)) return renamed;
24
- }
11
+ runDesktopLauncher(process.argv.slice(2), process.env, {
12
+ platform: process.platform,
13
+ desktopPackageDirectory,
14
+ resolveElectronPackageRoot() {
15
+ return path.dirname(requireFromDesktop.resolve("electron/package.json"));
16
+ },
17
+ readMacPlistValue(infoPlistPath, key) {
18
+ const result = spawnSync("plutil", ["-extract", key, "raw", "-o", "-", infoPlistPath], {
19
+ encoding: "utf8",
20
+ });
21
+ if (result.error) throw result.error;
22
+ if (result.status !== 0) throw new Error(`plutil exited with status ${result.status}`);
23
+ return result.stdout.replace(/\r?\n$/, "");
24
+ },
25
+ loadElectronResolver() {
26
+ return requireFromDesktop("electron");
27
+ },
28
+ runBranding() {
29
+ return spawnSync(process.execPath, [path.join(desktopPackageDirectory, "scripts", "rename-bundle.cjs")], {
30
+ stdio: "inherit",
31
+ }).status;
32
+ },
33
+ spawnElectron(executablePath, args, options) {
34
+ return spawn(executablePath, args, options);
35
+ },
36
+ writeStdout(line) {
37
+ process.stdout.write(`${line}\n`);
38
+ },
39
+ writeStderr(line) {
40
+ process.stderr.write(`${line}\n`);
41
+ },
42
+ }).then((outcome) => {
43
+ if (outcome.kind === "signal") {
44
+ process.kill(process.pid, outcome.signal);
45
+ } else {
46
+ process.exitCode = outcome.code;
25
47
  }
26
- return require("electron");
27
- }
28
-
29
- const env = { ...process.env };
30
- delete env.ELECTRON_RUN_AS_NODE;
31
-
32
- const child = spawn(resolveExecutable(), [appDir, ...process.argv.slice(2)], {
33
- stdio: "inherit",
34
- env,
35
- });
36
-
37
- child.on("exit", (code, signal) => {
38
- if (signal) process.kill(process.pid, signal);
39
- else process.exit(code ?? 0);
40
48
  });
@@ -8,15 +8,6 @@
8
8
  return ipcMatch?.[1]?.trim() || message;
9
9
  }
10
10
 
11
- // ../layout/src/constants.ts
12
- var LAYOUT_FULL_WIDTH_UNITS = 4;
13
- var LAYOUT_FULL_HEIGHT_UNITS = 6;
14
- var DEFAULT_LAYOUT_UNIT = 128;
15
- var CARD_GAP = 16;
16
- var CARD_WIDTH = DEFAULT_LAYOUT_UNIT * LAYOUT_FULL_WIDTH_UNITS + CARD_GAP * (LAYOUT_FULL_WIDTH_UNITS - 1);
17
- var CARD_HEIGHT = DEFAULT_LAYOUT_UNIT * LAYOUT_FULL_HEIGHT_UNITS + CARD_GAP * (LAYOUT_FULL_HEIGHT_UNITS - 1);
18
- var LAYOUT_MOBILE_BREAKPOINT_PX = CARD_WIDTH + CARD_GAP * 2;
19
-
20
11
  // ../../node_modules/@rupertsworld/event-target/dist/index.js
21
12
  var RESERVED_EVENT_KEYS = /* @__PURE__ */ new Set([
22
13
  "target",
@@ -81,10 +72,11 @@
81
72
  var ArtifactUpdatedEvent = defineEvent();
82
73
  var ArtifactRemovedEvent = defineEvent();
83
74
  var ArtifactContentChangedEvent = defineEvent();
84
- var ScreenCreatedEvent = defineEvent();
85
- var ScreenUpdatedEvent = defineEvent();
86
- var ScreenRemovedEvent = defineEvent();
87
- var ScreenChangedEvent = defineEvent();
75
+ var ChannelCreatedEvent = defineEvent();
76
+ var ChannelUpdatedEvent = defineEvent();
77
+ var ChannelRemovedEvent = defineEvent();
78
+ var ChannelChangedEvent = defineEvent();
79
+ var PinnedChannelsChangedEvent = defineEvent();
88
80
  var ArtifactFocusEvent = defineEvent();
89
81
  var ThemeChangedEvent = defineEvent();
90
82
 
@@ -6,7 +6,20 @@ var import_electron = require("electron");
6
6
  // src/connect-screen.ts
7
7
  var GET_CONNECT_SCREEN_INTENT_CHANNEL = "television:get-connect-screen-intent";
8
8
 
9
+ // src/native-navigation-key.ts
10
+ var NATIVE_NAVIGATION_KEY_CHANNEL = "television:navigation-key";
11
+ function isNativeNavigationKey(value) {
12
+ return value === "ArrowLeft" || value === "ArrowRight" || value === "ArrowUp" || value === "ArrowDown";
13
+ }
14
+
9
15
  // src/connect-preload.ts
16
+ import_electron.contextBridge.exposeInMainWorld("__televisionNativeBridge", {
17
+ onNavigationKey(callback) {
18
+ import_electron.ipcRenderer.on(NATIVE_NAVIGATION_KEY_CHANNEL, (_event, key) => {
19
+ if (isNativeNavigationKey(key)) callback(key);
20
+ });
21
+ }
22
+ });
10
23
  if (window.location.protocol === "file:") {
11
24
  import_electron.contextBridge.exposeInMainWorld("television", {
12
25
  getConnectScreenIntent: () => import_electron.ipcRenderer.invoke(GET_CONNECT_SCREEN_INTENT_CHANNEL),
package/dist/electron.cjs CHANGED
@@ -36,15 +36,6 @@ module.exports = __toCommonJS(index_exports);
36
36
  var import_electron2 = require("electron");
37
37
  var import_node_path2 = __toESM(require("node:path"), 1);
38
38
 
39
- // ../layout/src/constants.ts
40
- var LAYOUT_FULL_WIDTH_UNITS = 4;
41
- var LAYOUT_FULL_HEIGHT_UNITS = 6;
42
- var DEFAULT_LAYOUT_UNIT = 128;
43
- var CARD_GAP = 16;
44
- var CARD_WIDTH = DEFAULT_LAYOUT_UNIT * LAYOUT_FULL_WIDTH_UNITS + CARD_GAP * (LAYOUT_FULL_WIDTH_UNITS - 1);
45
- var CARD_HEIGHT = DEFAULT_LAYOUT_UNIT * LAYOUT_FULL_HEIGHT_UNITS + CARD_GAP * (LAYOUT_FULL_HEIGHT_UNITS - 1);
46
- var LAYOUT_MOBILE_BREAKPOINT_PX = CARD_WIDTH + CARD_GAP * 2;
47
-
48
39
  // ../../node_modules/@rupertsworld/event-target/dist/index.js
49
40
  var RESERVED_EVENT_KEYS = /* @__PURE__ */ new Set([
50
41
  "target",
@@ -109,10 +100,11 @@ var ArtifactCreatedEvent = defineEvent();
109
100
  var ArtifactUpdatedEvent = defineEvent();
110
101
  var ArtifactRemovedEvent = defineEvent();
111
102
  var ArtifactContentChangedEvent = defineEvent();
112
- var ScreenCreatedEvent = defineEvent();
113
- var ScreenUpdatedEvent = defineEvent();
114
- var ScreenRemovedEvent = defineEvent();
115
- var ScreenChangedEvent = defineEvent();
103
+ var ChannelCreatedEvent = defineEvent();
104
+ var ChannelUpdatedEvent = defineEvent();
105
+ var ChannelRemovedEvent = defineEvent();
106
+ var ChannelChangedEvent = defineEvent();
107
+ var PinnedChannelsChangedEvent = defineEvent();
116
108
  var ArtifactFocusEvent = defineEvent();
117
109
  var ThemeChangedEvent = defineEvent();
118
110
 
@@ -182,6 +174,7 @@ function buildRemoteURL(serverURL, token, desktopAppVersion) {
182
174
  }
183
175
 
184
176
  // src/connect-preflight.ts
177
+ var HTTP_OK = 200;
185
178
  var HTTP_UNAUTHORIZED = 401;
186
179
  var DEFAULT_PREFLIGHT_TIMEOUT_MS = 8e3;
187
180
  var NOT_TV_SERVER_MESSAGE = "This URL doesn't seem to be a Television server \u2014 please check it.";
@@ -195,19 +188,19 @@ function unreachable(serverURL) {
195
188
  function notTVServer() {
196
189
  return { ok: false, code: "not-tv-server", message: NOT_TV_SERVER_MESSAGE };
197
190
  }
198
- function isDisplayResponse(body) {
199
- if (typeof body !== "object" || body === null) return false;
200
- const candidate = body;
201
- return (typeof candidate.activeScreenID === "string" || candidate.activeScreenID === null) && (typeof candidate.activeThemeName === "string" || candidate.activeThemeName === null) && typeof candidate.acpEnabled === "boolean";
191
+ function isDesktopConnectCheckResponse(body) {
192
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return false;
193
+ return body.product === "television";
202
194
  }
203
195
  async function preflightConnection(options) {
204
196
  const fetchImpl = options.fetchImpl ?? fetch;
205
- const probeURL = new URL(options.probePath ?? "/display", options.serverURL).toString();
197
+ const probeURL = new URL("/desktop/connect-check", options.serverURL);
198
+ probeURL.searchParams.set("desktopAppVersion", options.desktopAppVersion);
206
199
  const headers = {};
207
200
  if (options.token) headers.Authorization = `Bearer ${options.token}`;
208
201
  let response;
209
202
  try {
210
- response = await fetchImpl(probeURL, {
203
+ response = await fetchImpl(probeURL.toString(), {
211
204
  method: "GET",
212
205
  headers,
213
206
  signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS)
@@ -221,7 +214,7 @@ async function preflightConnection(options) {
221
214
  }
222
215
  return { ok: false, code: "auth-rejected", message: "Token rejected" };
223
216
  }
224
- if (!response.ok) {
217
+ if (response.status !== HTTP_OK) {
225
218
  return notTVServer();
226
219
  }
227
220
  let body;
@@ -230,7 +223,7 @@ async function preflightConnection(options) {
230
223
  } catch {
231
224
  return notTVServer();
232
225
  }
233
- if (!isDisplayResponse(body)) {
226
+ if (!isDesktopConnectCheckResponse(body)) {
234
227
  return notTVServer();
235
228
  }
236
229
  return { ok: true };
@@ -264,6 +257,26 @@ function saveConnection(connection) {
264
257
  // src/connect-screen.ts
265
258
  var GET_CONNECT_SCREEN_INTENT_CHANNEL = "television:get-connect-screen-intent";
266
259
 
260
+ // src/native-navigation-key.ts
261
+ var NATIVE_NAVIGATION_KEY_CHANNEL = "television:navigation-key";
262
+ function isNativeNavigationKey(value) {
263
+ return value === "ArrowLeft" || value === "ArrowRight" || value === "ArrowUp" || value === "ArrowDown";
264
+ }
265
+ function handleNativeNavigationKey(event, input, platform, deliver) {
266
+ if (input.type !== "keyDown" || input.isComposing || !isNativeNavigationKey(input.key)) {
267
+ return false;
268
+ }
269
+ const matchesPlatformChord = platform === "darwin" ? input.alt && !input.control && !input.meta && !input.shift : input.control && !input.alt && !input.meta && !input.shift;
270
+ if (!matchesPlatformChord) return false;
271
+ event.preventDefault();
272
+ deliver(input.key);
273
+ return true;
274
+ }
275
+
276
+ // src/window-measures.ts
277
+ var WINDOW_MIN_WIDTH_PX = 500;
278
+ var WINDOW_MIN_HEIGHT_PX = 500;
279
+
267
280
  // src/index.ts
268
281
  import_electron2.app.setName("Television");
269
282
  var WINDOW_WIDTH = 1400;
@@ -320,6 +333,8 @@ var App = class {
320
333
  icon: import_node_path2.default.join(__dirname, "..", "assets", "icon.png"),
321
334
  width: WINDOW_WIDTH,
322
335
  height: WINDOW_HEIGHT,
336
+ minWidth: WINDOW_MIN_WIDTH_PX,
337
+ minHeight: WINDOW_MIN_HEIGHT_PX,
323
338
  show: false,
324
339
  backgroundColor: "#000000",
325
340
  titleBarStyle: "hidden",
@@ -367,29 +382,39 @@ var App = class {
367
382
  return { ok: false, message };
368
383
  }
369
384
  try {
385
+ const desktopAppVersion = resolveDesktopAppVersion(import_electron2.app.getVersion(), process.env);
370
386
  const preflight = await preflightConnection({
371
387
  serverURL: connection.serverURL,
372
- token: connection.token
388
+ token: connection.token,
389
+ desktopAppVersion
373
390
  });
374
391
  if (!preflight.ok) {
375
392
  return { ok: false, message: preflight.message };
376
393
  }
377
394
  saveConnection(connection);
378
- await this.loadRemote(connection);
395
+ await this.loadRemote(connection, desktopAppVersion);
379
396
  return { ok: true };
380
397
  } catch (error) {
381
398
  const message = error instanceof Error ? error.message : "Failed to connect";
382
399
  return { ok: false, message };
383
400
  }
384
401
  }
385
- async loadRemote(connection) {
402
+ async loadRemote(connection, desktopAppVersion) {
386
403
  if (!this.window) return;
387
- await this.window.loadURL(
388
- buildRemoteURL(connection.serverURL, connection.token, resolveDesktopAppVersion(import_electron2.app.getVersion(), process.env))
389
- );
404
+ await this.window.loadURL(buildRemoteURL(connection.serverURL, connection.token, desktopAppVersion));
390
405
  }
391
406
  installWindowOpenHandler() {
392
407
  import_electron2.app.on("web-contents-created", (_event, contents) => {
408
+ if (contents.getType() === "webview") {
409
+ const host = contents.hostWebContents;
410
+ if (host) {
411
+ contents.on("before-input-event", (event, input) => {
412
+ handleNativeNavigationKey(event, input, process.platform, (key) => {
413
+ host.send(NATIVE_NAVIGATION_KEY_CHANNEL, key);
414
+ });
415
+ });
416
+ }
417
+ }
393
418
  contents.setWindowOpenHandler(({ url }) => {
394
419
  if (isExternalOpenURL(url)) {
395
420
  if (process.env.TV_TEST_MODE === "true") {
@@ -41,7 +41,6 @@ var documentGuid = makeBridgeGuid();
41
41
  function postBridgeReady() {
42
42
  import_electron.ipcRenderer.sendToHost(BRIDGE_CHANNEL, {
43
43
  type: "bridge-ready",
44
- url: window.location.href,
45
44
  guid: documentGuid
46
45
  });
47
46
  }
@@ -88,85 +87,70 @@ function startProxyContentPoll() {
88
87
  const SLOW_POLL_MS = cadence?.slowMs ?? PRODUCTION_SLOW_POLL_MS;
89
88
  let baselineETag = null;
90
89
  let delayMs = NORMAL_POLL_MS;
91
- let stopped = false;
92
- const poll = () => {
93
- void (async () => {
94
- try {
95
- const response = await window.fetch(capturedURL, { method: "HEAD" });
96
- if (!response.ok) throw new Error("Artifact poll failed");
97
- const etag = response.headers.get("ETag");
98
- if (!etag) throw new Error("Artifact poll missing ETag");
99
- delayMs = NORMAL_POLL_MS;
100
- if (baselineETag === null) {
101
- baselineETag = etag;
102
- } else if (etag !== baselineETag) {
103
- baselineETag = etag;
104
- import_electron.ipcRenderer.sendToHost(BRIDGE_CHANNEL, { type: "proxy-content-changed" });
90
+ let generation = 0;
91
+ let active = false;
92
+ let queuedTimer = null;
93
+ let activeAbortController = null;
94
+ const stopPollLoop = () => {
95
+ if (!active) return;
96
+ active = false;
97
+ generation += 1;
98
+ if (queuedTimer !== null) {
99
+ window.clearTimeout(queuedTimer);
100
+ queuedTimer = null;
101
+ }
102
+ activeAbortController?.abort();
103
+ activeAbortController = null;
104
+ };
105
+ const startPollLoop = () => {
106
+ if (active) return;
107
+ active = true;
108
+ const loopGeneration = ++generation;
109
+ const poll = () => {
110
+ if (!active || loopGeneration !== generation) return;
111
+ queuedTimer = null;
112
+ const AbortControllerConstructor = window.AbortController;
113
+ const abortController = typeof AbortControllerConstructor === "function" ? new AbortControllerConstructor() : null;
114
+ activeAbortController = abortController;
115
+ void (async () => {
116
+ try {
117
+ const response = await window.fetch(capturedURL, {
118
+ method: "HEAD",
119
+ ...abortController ? { signal: abortController.signal } : {}
120
+ });
121
+ if (!active || loopGeneration !== generation) return;
122
+ if (!response.ok) throw new Error("Artifact poll failed");
123
+ const etag = response.headers.get("ETag");
124
+ if (!etag) throw new Error("Artifact poll missing ETag");
125
+ delayMs = NORMAL_POLL_MS;
126
+ if (baselineETag === null) {
127
+ baselineETag = etag;
128
+ } else if (etag !== baselineETag) {
129
+ baselineETag = etag;
130
+ import_electron.ipcRenderer.sendToHost(BRIDGE_CHANNEL, { type: "proxy-content-changed" });
131
+ }
132
+ } catch {
133
+ if (active && loopGeneration === generation) {
134
+ delayMs = Math.min(SLOW_POLL_MS, delayMs * 2);
135
+ }
136
+ } finally {
137
+ if (activeAbortController === abortController) {
138
+ activeAbortController = null;
139
+ }
140
+ if (active && loopGeneration === generation) {
141
+ queuedTimer = window.setTimeout(poll, delayMs);
142
+ }
105
143
  }
106
- } catch {
107
- delayMs = Math.min(SLOW_POLL_MS, delayMs * 2);
108
- } finally {
109
- if (!stopped) window.setTimeout(poll, delayMs);
110
- }
111
- })();
144
+ })();
145
+ };
146
+ poll();
112
147
  };
113
- window.addEventListener("pagehide", () => {
114
- stopped = true;
148
+ window.addEventListener("pagehide", stopPollLoop);
149
+ window.addEventListener("pageshow", (event) => {
150
+ if (event.persisted === true) {
151
+ startPollLoop();
152
+ }
115
153
  });
116
- poll();
154
+ startPollLoop();
117
155
  }
118
156
  startProxyContentPoll();
119
- function isTextEditingTarget(target) {
120
- if (!(target instanceof Element)) {
121
- return false;
122
- }
123
- const editable = target.closest(
124
- "input, textarea, select, [contenteditable], [role='textbox'], [role='searchbox'], [role='combobox'], [role='spinbutton'], [role='slider']"
125
- );
126
- if (!editable) {
127
- return false;
128
- }
129
- if (editable.hasAttribute("contenteditable")) {
130
- return editable.getAttribute("contenteditable")?.toLowerCase() !== "false";
131
- }
132
- return true;
133
- }
134
- window.addEventListener(
135
- "keydown",
136
- (event) => {
137
- if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
138
- return;
139
- }
140
- if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
141
- return;
142
- }
143
- if (isTextEditingTarget(event.target)) {
144
- return;
145
- }
146
- event.preventDefault();
147
- import_electron.ipcRenderer.sendToHost(BRIDGE_CHANNEL, {
148
- type: "filmstrip-key",
149
- direction: event.key === "ArrowRight" ? 1 : -1
150
- });
151
- },
152
- { capture: true }
153
- );
154
- window.addEventListener(
155
- "wheel",
156
- (event) => {
157
- const usingShiftFallback = event.shiftKey && event.deltaX === 0 && event.deltaY !== 0;
158
- const horizontalDelta = usingShiftFallback ? event.deltaY : event.deltaX;
159
- const horizontalMagnitude = Math.abs(horizontalDelta);
160
- const verticalMagnitude = usingShiftFallback ? 0 : Math.abs(event.deltaY);
161
- if (horizontalMagnitude <= verticalMagnitude) {
162
- return;
163
- }
164
- const payload = {
165
- type: "wheel",
166
- deltaX: horizontalDelta,
167
- shiftKey: event.shiftKey
168
- };
169
- import_electron.ipcRenderer.sendToHost(BRIDGE_CHANNEL, payload);
170
- },
171
- { passive: false }
172
- );
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@telepath-computer/television-desktop",
3
3
  "productName": "Television",
4
- "version": "0.1.206",
4
+ "version": "0.1.208",
5
5
  "license": "MIT",
6
6
  "engines": {
7
- "node": ">=22"
7
+ "node": ">=22.12.0"
8
8
  },
9
9
  "type": "module",
10
10
  "main": "dist/electron.cjs",
@@ -15,7 +15,9 @@
15
15
  "dist/**",
16
16
  "bin/**",
17
17
  "assets/**",
18
- "scripts/rename-bundle.cjs"
18
+ "scripts/launcher-core.cjs",
19
+ "scripts/rename-bundle.cjs",
20
+ "scripts/branding-core.cjs"
19
21
  ],
20
22
  "exports": {
21
23
  ".": "./src/index.ts"
@@ -26,9 +28,10 @@
26
28
  "type-check": "tsc -p tsconfig.json --noEmit"
27
29
  },
28
30
  "dependencies": {
29
- "electron": "35.7.5"
31
+ "electron": "43.2.0"
30
32
  },
31
33
  "devDependencies": {
34
+ "@telepath-computer/television-artifact": "*",
32
35
  "@telepath-computer/television-shared": "*"
33
36
  }
34
37
  }
@@ -0,0 +1,204 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+
4
+ const UPSTREAM_PATH = "Electron.app/Contents/MacOS/Electron";
5
+ const BRANDED_PATH = "Television.app/Contents/MacOS/Television";
6
+ const IDENTITY = {
7
+ CFBundleName: "Television",
8
+ CFBundleDisplayName: "Television",
9
+ CFBundleExecutable: "Television",
10
+ CFBundleIdentifier: "computer.telepath.television",
11
+ };
12
+ const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
13
+
14
+ function runMacBranding(boundaries) {
15
+ if (boundaries.platform !== "darwin") return 0;
16
+
17
+ try {
18
+ const runtime = inspectRuntime(boundaries);
19
+ if (runtime.layout === "branded") {
20
+ refreshLaunchServices(boundaries, runtime.bundlePath);
21
+ return 0;
22
+ }
23
+ if (runtime.layout !== "upstream") {
24
+ throw new Error(runtime.reason);
25
+ }
26
+ if (fs.existsSync(runtime.brandedBundlePath)) {
27
+ throw new Error("the branded Electron bundle already exists beside the upstream runtime");
28
+ }
29
+
30
+ transformRuntime(boundaries, runtime);
31
+ const branded = inspectRuntime(boundaries);
32
+ if (branded.layout !== "branded") {
33
+ throw new Error(`the branded Electron runtime is invalid: ${branded.reason}`);
34
+ }
35
+ refreshLaunchServices(boundaries, branded.bundlePath);
36
+ return 0;
37
+ } catch (error) {
38
+ boundaries.writeStderr(`[television-desktop] could not prepare the Television bundle: ${errorMessage(error)}`);
39
+ return 1;
40
+ }
41
+ }
42
+
43
+ function inspectRuntime(boundaries) {
44
+ let packageRoot;
45
+ let targetVersion;
46
+ try {
47
+ const desktopManifest = readJSON(path.join(boundaries.desktopPackageDirectory, "package.json"));
48
+ targetVersion = desktopManifest.dependencies?.electron;
49
+ if (typeof targetVersion !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(targetVersion)) {
50
+ return invalid("the desktop Electron dependency is missing or is not an exact version");
51
+ }
52
+ packageRoot = boundaries.resolveElectronPackageRoot();
53
+ const packageManifest = readJSON(path.join(packageRoot, "package.json"));
54
+ if (packageManifest.version !== targetVersion) {
55
+ return invalid(`the Electron package version does not match ${targetVersion}`);
56
+ }
57
+ } catch (error) {
58
+ return invalid(`the Electron package could not be resolved: ${errorMessage(error)}`);
59
+ }
60
+
61
+ const dist = path.join(packageRoot, "dist");
62
+ const pathFile = path.join(packageRoot, "path.txt");
63
+ let recordedPath;
64
+ let recordedVersion;
65
+ try {
66
+ recordedPath = fs.readFileSync(pathFile, "utf8");
67
+ recordedVersion = fs.readFileSync(path.join(dist, "version"), "utf8");
68
+ } catch (error) {
69
+ return invalid(`the generated Electron runtime is incomplete: ${errorMessage(error)}`);
70
+ }
71
+ if (recordedVersion !== targetVersion) {
72
+ return invalid(`the generated Electron runtime version does not match ${targetVersion}`);
73
+ }
74
+
75
+ if (recordedPath === BRANDED_PATH) {
76
+ const bundlePath = path.join(dist, "Television.app");
77
+ const validity = inspectBundle(boundaries, bundlePath, "Television", IDENTITY);
78
+ return validity === null
79
+ ? { layout: "branded", packageRoot, pathFile, bundlePath }
80
+ : invalid(validity);
81
+ }
82
+ if (recordedPath === UPSTREAM_PATH) {
83
+ const bundlePath = path.join(dist, "Electron.app");
84
+ const validity = inspectBundle(boundaries, bundlePath, "Electron", { CFBundleExecutable: "Electron" });
85
+ return validity === null
86
+ ? {
87
+ layout: "upstream",
88
+ packageRoot,
89
+ pathFile,
90
+ bundlePath,
91
+ brandedBundlePath: path.join(dist, "Television.app"),
92
+ }
93
+ : invalid(validity);
94
+ }
95
+ return invalid(`path.txt contains an unexpected macOS executable path: ${JSON.stringify(recordedPath)}`);
96
+ }
97
+
98
+ function inspectBundle(boundaries, bundlePath, executableName, expectedIdentity) {
99
+ const contents = path.join(bundlePath, "Contents");
100
+ const executable = path.join(contents, "MacOS", executableName);
101
+ const plist = path.join(contents, "Info.plist");
102
+ const frameworks = path.join(contents, "Frameworks");
103
+ if (!executableIsValid(executable) || !regularFile(plist) || !nonemptyDirectory(frameworks)) {
104
+ return `the ${path.basename(bundlePath)} layout is incomplete`;
105
+ }
106
+ for (const [key, expected] of Object.entries(expectedIdentity)) {
107
+ const result = boundaries.runCommand("plutil", ["-extract", key, "raw", "-o", "-", plist]);
108
+ if (!commandSucceeded(result)) {
109
+ return `could not read ${key} from ${plist}: ${commandFailure(result)}`;
110
+ }
111
+ const actual = typeof result.stdout === "string" ? result.stdout.replace(/\r?\n$/, "") : "";
112
+ if (actual !== expected) {
113
+ return `${key} is ${JSON.stringify(actual)} instead of ${JSON.stringify(expected)}`;
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ function transformRuntime(boundaries, runtime) {
120
+ const brandedBundle = runtime.brandedBundlePath;
121
+ fs.renameSync(runtime.bundlePath, brandedBundle);
122
+
123
+ const contents = path.join(brandedBundle, "Contents");
124
+ fs.renameSync(
125
+ path.join(contents, "MacOS", "Electron"),
126
+ path.join(contents, "MacOS", "Television"),
127
+ );
128
+ fs.copyFileSync(
129
+ path.join(boundaries.desktopPackageDirectory, "assets", "icon.icns"),
130
+ path.join(contents, "Resources", "electron.icns"),
131
+ );
132
+
133
+ const plist = path.join(contents, "Info.plist");
134
+ for (const [key, value] of Object.entries(IDENTITY)) {
135
+ const result = boundaries.runCommand("plutil", ["-replace", key, "-string", value, plist]);
136
+ if (!commandSucceeded(result)) {
137
+ throw new Error(`could not set ${key} in ${plist}: ${commandFailure(result)}`);
138
+ }
139
+ }
140
+
141
+ const temporaryPathFile = path.join(runtime.packageRoot, "path.txt.tmp");
142
+ fs.writeFileSync(temporaryPathFile, BRANDED_PATH, { encoding: "utf8", flag: "wx" });
143
+ fs.renameSync(temporaryPathFile, runtime.pathFile);
144
+ }
145
+
146
+ function refreshLaunchServices(boundaries, bundlePath) {
147
+ const result = boundaries.runCommand(LSREGISTER, ["-f", bundlePath]);
148
+ if (!commandSucceeded(result)) {
149
+ boundaries.writeStderr(
150
+ `[television-desktop] warning: could not refresh LaunchServices: ${commandFailure(result)}`,
151
+ );
152
+ }
153
+ }
154
+
155
+ function commandSucceeded(result) {
156
+ return result?.error === undefined && result?.status === 0;
157
+ }
158
+
159
+ function commandFailure(result) {
160
+ if (result?.error !== undefined) return errorMessage(result.error);
161
+ return result?.status === null
162
+ ? "the command exited without a status"
163
+ : `the command exited with status ${result?.status}`;
164
+ }
165
+
166
+ function executableIsValid(file) {
167
+ try {
168
+ if (!fs.statSync(file).isFile()) return false;
169
+ fs.accessSync(file, fs.constants.X_OK);
170
+ return true;
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+
176
+ function regularFile(file) {
177
+ try {
178
+ return fs.statSync(file).isFile();
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
183
+
184
+ function nonemptyDirectory(directory) {
185
+ try {
186
+ return fs.statSync(directory).isDirectory() && fs.readdirSync(directory).length > 0;
187
+ } catch {
188
+ return false;
189
+ }
190
+ }
191
+
192
+ function readJSON(file) {
193
+ return JSON.parse(fs.readFileSync(file, "utf8"));
194
+ }
195
+
196
+ function invalid(reason) {
197
+ return { layout: "invalid", reason };
198
+ }
199
+
200
+ function errorMessage(error) {
201
+ return error instanceof Error ? error.message : String(error);
202
+ }
203
+
204
+ module.exports = { runMacBranding };
@@ -0,0 +1,247 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+
4
+ const SETUP_MESSAGE = "[television-desktop] Preparing the Electron runtime. A download may be required; this can take a moment.";
5
+ const DOWNLOAD_MESSAGE = "[television-desktop] Could not download the Electron runtime. Check your network connection and run tv-desktop again.";
6
+ const INVALID_MESSAGE = "[television-desktop] The Electron runtime is incomplete or does not match this Television release.";
7
+ const BRANDING_MESSAGE = "[television-desktop] Could not prepare the Television application bundle.";
8
+ const REINSTALL_MESSAGE = "Reinstall it with: npm i -g @telepath-computer/television-desktop@latest";
9
+ const BRANDED_IDENTITY = {
10
+ CFBundleName: "Television",
11
+ CFBundleDisplayName: "Television",
12
+ CFBundleExecutable: "Television",
13
+ CFBundleIdentifier: "computer.telepath.television",
14
+ };
15
+
16
+ async function runDesktopLauncher(userArgs, env, boundaries) {
17
+ try {
18
+ const packageRoot = boundaries.resolveElectronPackageRoot();
19
+ let state = inspectRuntime(packageRoot, boundaries);
20
+
21
+ if (state.kind === "absent") {
22
+ boundaries.writeStdout(SETUP_MESSAGE);
23
+ try {
24
+ boundaries.loadElectronResolver();
25
+ } catch {
26
+ boundaries.writeStderr(DOWNLOAD_MESSAGE);
27
+ return exit(1);
28
+ }
29
+ state = inspectRuntime(packageRoot, boundaries);
30
+ }
31
+
32
+ if (state.kind === "invalid" || state.kind === "absent") {
33
+ writeReinstallFailure(boundaries, INVALID_MESSAGE);
34
+ return exit(1);
35
+ }
36
+
37
+ if (boundaries.platform === "darwin" && state.kind === "upstream-macos") {
38
+ let brandingStatus;
39
+ try {
40
+ brandingStatus = boundaries.runBranding();
41
+ } catch {
42
+ writeReinstallFailure(boundaries, BRANDING_MESSAGE);
43
+ return exit(1);
44
+ }
45
+ if (brandingStatus !== 0) {
46
+ writeReinstallFailure(boundaries, BRANDING_MESSAGE);
47
+ return exit(1);
48
+ }
49
+ state = inspectRuntime(packageRoot, boundaries);
50
+ if (state.kind !== "branded-macos") {
51
+ writeReinstallFailure(boundaries, BRANDING_MESSAGE);
52
+ return exit(1);
53
+ }
54
+ }
55
+
56
+ if (state.kind !== "native" && state.kind !== "branded-macos") {
57
+ writeReinstallFailure(boundaries, INVALID_MESSAGE);
58
+ return exit(1);
59
+ }
60
+
61
+ const childEnv = { ...env };
62
+ delete childEnv.ELECTRON_RUN_AS_NODE;
63
+ const child = boundaries.spawnElectron(
64
+ state.executablePath,
65
+ [boundaries.desktopPackageDirectory, ...userArgs],
66
+ { env: childEnv, stdio: "inherit" },
67
+ );
68
+ return await childOutcome(child, boundaries);
69
+ } catch (error) {
70
+ boundaries.writeStderr(`[television-desktop] Could not start Television: ${errorDetail(error)}`);
71
+ return exit(1);
72
+ }
73
+ }
74
+
75
+ function inspectRuntime(packageRoot, boundaries) {
76
+ const dist = path.join(packageRoot, "dist");
77
+ const versionFile = path.join(dist, "version");
78
+ const pathFile = path.join(packageRoot, "path.txt");
79
+
80
+ if (runtimeIsAbsent(dist, versionFile, pathFile)) return { kind: "absent" };
81
+
82
+ let targetVersion;
83
+ let packageVersion;
84
+ let runtimeVersion;
85
+ let recordedPath;
86
+ try {
87
+ const desktopManifest = readJSON(path.join(boundaries.desktopPackageDirectory, "package.json"));
88
+ targetVersion = desktopManifest.dependencies?.electron;
89
+ if (typeof targetVersion !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(targetVersion)) {
90
+ return { kind: "invalid" };
91
+ }
92
+ packageVersion = readJSON(path.join(packageRoot, "package.json")).version;
93
+ runtimeVersion = readRegularFile(versionFile);
94
+ recordedPath = readRegularFile(pathFile);
95
+ } catch {
96
+ return { kind: "invalid" };
97
+ }
98
+ if (packageVersion !== targetVersion || runtimeVersion !== targetVersion) {
99
+ return { kind: "invalid" };
100
+ }
101
+
102
+ if (boundaries.platform === "darwin") {
103
+ return inspectMacRuntime(packageRoot, recordedPath, boundaries);
104
+ }
105
+ if (boundaries.platform === "linux" || boundaries.platform === "win32") {
106
+ const executableName = boundaries.platform === "win32" ? "electron.exe" : "electron";
107
+ const executablePath = path.join(dist, executableName);
108
+ return recordedPath === executableName && executableIsValid(executablePath)
109
+ ? { kind: "native", executablePath }
110
+ : { kind: "invalid" };
111
+ }
112
+ return { kind: "invalid" };
113
+ }
114
+
115
+ function inspectMacRuntime(packageRoot, recordedPath, boundaries) {
116
+ const dist = path.join(packageRoot, "dist");
117
+ if (recordedPath === "Television.app/Contents/MacOS/Television") {
118
+ const bundle = path.join(dist, "Television.app");
119
+ return macBundleIsValid(bundle, "Television", BRANDED_IDENTITY, boundaries)
120
+ ? {
121
+ kind: "branded-macos",
122
+ executablePath: path.join(bundle, "Contents", "MacOS", "Television"),
123
+ }
124
+ : { kind: "invalid" };
125
+ }
126
+ if (recordedPath === "Electron.app/Contents/MacOS/Electron") {
127
+ const bundle = path.join(dist, "Electron.app");
128
+ const brandedBundle = path.join(dist, "Television.app");
129
+ return !fs.existsSync(brandedBundle)
130
+ && macBundleIsValid(bundle, "Electron", { CFBundleExecutable: "Electron" }, boundaries)
131
+ ? {
132
+ kind: "upstream-macos",
133
+ executablePath: path.join(bundle, "Contents", "MacOS", "Electron"),
134
+ }
135
+ : { kind: "invalid" };
136
+ }
137
+ return { kind: "invalid" };
138
+ }
139
+
140
+ function macBundleIsValid(bundle, executableName, expectedIdentity, boundaries) {
141
+ const contents = path.join(bundle, "Contents");
142
+ const executable = path.join(contents, "MacOS", executableName);
143
+ const plist = path.join(contents, "Info.plist");
144
+ const frameworks = path.join(contents, "Frameworks");
145
+ if (!executableIsValid(executable) || !regularFile(plist) || !nonemptyDirectory(frameworks)) {
146
+ return false;
147
+ }
148
+ try {
149
+ return Object.entries(expectedIdentity).every(
150
+ ([key, expected]) => boundaries.readMacPlistValue(plist, key) === expected,
151
+ );
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ function runtimeIsAbsent(dist, versionFile, pathFile) {
158
+ if (fs.existsSync(pathFile) || fs.existsSync(versionFile)) return false;
159
+ if (!fs.existsSync(dist)) return true;
160
+ try {
161
+ return fs.statSync(dist).isDirectory() && fs.readdirSync(dist).length === 0;
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+
167
+ function readRegularFile(file) {
168
+ if (!fs.statSync(file).isFile()) throw new Error(`${file} is not a regular file`);
169
+ return fs.readFileSync(file, "utf8");
170
+ }
171
+
172
+ function executableIsValid(file) {
173
+ try {
174
+ if (!fs.statSync(file).isFile()) return false;
175
+ fs.accessSync(file, fs.constants.X_OK);
176
+ return true;
177
+ } catch {
178
+ return false;
179
+ }
180
+ }
181
+
182
+ function regularFile(file) {
183
+ try {
184
+ return fs.statSync(file).isFile();
185
+ } catch {
186
+ return false;
187
+ }
188
+ }
189
+
190
+ function nonemptyDirectory(directory) {
191
+ try {
192
+ return fs.statSync(directory).isDirectory() && fs.readdirSync(directory).length > 0;
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+
198
+ function childOutcome(child, boundaries) {
199
+ return new Promise((resolve) => {
200
+ let settled = false;
201
+ const finish = (outcome) => {
202
+ if (settled) return;
203
+ settled = true;
204
+ child.removeListener("error", onError);
205
+ child.removeListener("exit", onExit);
206
+ resolve(outcome);
207
+ };
208
+ const onError = (error) => {
209
+ if (settled) return;
210
+ boundaries.writeStderr(`[television-desktop] Could not start Television: ${errorDetail(error)}`);
211
+ finish(exit(1));
212
+ };
213
+ const onExit = (code, signal) => {
214
+ if (settled) return;
215
+ if (signal) {
216
+ finish({ kind: "signal", signal });
217
+ } else if (Number.isInteger(code)) {
218
+ finish(exit(code));
219
+ } else {
220
+ boundaries.writeStderr("[television-desktop] Television exited without a status or signal.");
221
+ finish(exit(1));
222
+ }
223
+ };
224
+ child.once("error", onError);
225
+ child.once("exit", onExit);
226
+ });
227
+ }
228
+
229
+ function writeReinstallFailure(boundaries, message) {
230
+ boundaries.writeStderr(message);
231
+ boundaries.writeStderr(REINSTALL_MESSAGE);
232
+ }
233
+
234
+ function readJSON(file) {
235
+ return JSON.parse(fs.readFileSync(file, "utf8"));
236
+ }
237
+
238
+ function errorDetail(error) {
239
+ const message = error instanceof Error ? error.message : String(error);
240
+ return message.replace(/\r?\n/g, " ");
241
+ }
242
+
243
+ function exit(code) {
244
+ return { kind: "exit", code };
245
+ }
246
+
247
+ module.exports = { runDesktopLauncher };
@@ -1,65 +1,31 @@
1
1
  #!/usr/bin/env node
2
- // Renames the bundled Electron.app to Television.app (bundle + inner
3
- // executable + plist) so macOS labels the Dock and menu bar as
4
- // "Television" instead of "Electron". macOS reads the running-app name
5
- // from the bundle's filesystem identity; just editing Info.plist isn't
6
- // enough — the .app directory and Contents/MacOS/<exe> have to match.
7
- // Scoped to this package's resolved electron install. Idempotent; no-ops
8
- // once the rename is complete.
9
- //
10
- // Invoked lazily on first launch by bin/tv-desktop.cjs. We can't use an
11
- // npm `postinstall` hook because npm runs our package's postinstall
12
- // before electron's postinstall has downloaded Electron.app — the rename
13
- // has nothing to rename at install time.
2
+ // Electron 43 installs its native runtime lazily. The desktop launcher invokes
3
+ // this entry on its macOS branding path, and the core validates the complete
4
+ // Electron runtime before changing its bundle identity.
14
5
 
15
- const { execFileSync } = require("node:child_process");
6
+ const { spawnSync } = require("node:child_process");
7
+ const { createRequire } = require("node:module");
16
8
  const path = require("node:path");
17
- const fs = require("node:fs");
18
-
19
- if (process.platform !== "darwin") process.exit(0);
20
-
21
- let electronEntry;
22
- try {
23
- electronEntry = require.resolve("electron");
24
- } catch {
25
- process.exit(0);
26
- }
27
-
28
- const NAME = "Television";
29
- const BUNDLE_ID = "computer.telepath.television";
30
- const distDir = path.join(path.dirname(electronEntry), "dist");
31
- const oldApp = path.join(distDir, "Electron.app");
32
- const newApp = path.join(distDir, `${NAME}.app`);
33
- const targetApp = fs.existsSync(newApp) ? newApp : oldApp;
34
-
35
- if (!fs.existsSync(targetApp)) process.exit(0);
36
-
37
- try {
38
- if (targetApp === oldApp) fs.renameSync(oldApp, newApp);
39
-
40
- const macosDir = path.join(newApp, "Contents", "MacOS");
41
- const oldExe = path.join(macosDir, "Electron");
42
- const newExe = path.join(macosDir, NAME);
43
- if (fs.existsSync(oldExe) && !fs.existsSync(newExe)) {
44
- fs.renameSync(oldExe, newExe);
45
- }
46
-
47
- const ourIcon = path.join(__dirname, "..", "assets", "icon.icns");
48
- const bundleIcon = path.join(newApp, "Contents", "Resources", "electron.icns");
49
- if (fs.existsSync(ourIcon) && fs.existsSync(path.dirname(bundleIcon))) {
50
- fs.copyFileSync(ourIcon, bundleIcon);
51
- }
52
-
53
- const plist = path.join(newApp, "Contents", "Info.plist");
54
- execFileSync("plutil", ["-replace", "CFBundleName", "-string", NAME, plist]);
55
- execFileSync("plutil", ["-replace", "CFBundleDisplayName", "-string", NAME, plist]);
56
- execFileSync("plutil", ["-replace", "CFBundleExecutable", "-string", NAME, plist]);
57
- execFileSync("plutil", ["-replace", "CFBundleIdentifier", "-string", BUNDLE_ID, plist]);
58
-
59
- execFileSync(
60
- "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
61
- ["-f", newApp],
62
- );
63
- } catch (err) {
64
- console.warn(`[television-desktop] could not rename Electron bundle: ${err.message}`);
65
- }
9
+ const { runMacBranding } = require("./branding-core.cjs");
10
+
11
+ const desktopPackageDirectory = path.join(__dirname, "..");
12
+ const requireFromDesktop = createRequire(path.join(desktopPackageDirectory, "package.json"));
13
+
14
+ process.exitCode = runMacBranding({
15
+ platform: process.platform,
16
+ desktopPackageDirectory,
17
+ resolveElectronPackageRoot() {
18
+ return path.dirname(requireFromDesktop.resolve("electron/package.json"));
19
+ },
20
+ runCommand(command, args) {
21
+ const result = spawnSync(command, args, { encoding: "utf8" });
22
+ return {
23
+ status: result.status,
24
+ stdout: result.stdout,
25
+ error: result.error,
26
+ };
27
+ },
28
+ writeStderr(line) {
29
+ process.stderr.write(`${line}\n`);
30
+ },
31
+ });