@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 +20 -14
- package/bin/tv-desktop.cjs +41 -33
- package/dist/connect-page.cjs +5 -13
- package/dist/connect-preload.cjs +13 -0
- package/dist/electron.cjs +52 -27
- package/dist/webview-bridge-preload.cjs +62 -78
- package/package.json +7 -4
- package/scripts/branding-core.cjs +204 -0
- package/scripts/launcher-core.cjs +247 -0
- package/scripts/rename-bundle.cjs +28 -62
package/README.md
CHANGED
|
@@ -1,27 +1,33 @@
|
|
|
1
1
|
# @telepath-computer/television-desktop
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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).
|
package/bin/tv-desktop.cjs
CHANGED
|
@@ -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
|
|
6
|
+
const { runDesktopLauncher } = require("../scripts/launcher-core.cjs");
|
|
5
7
|
|
|
6
|
-
const
|
|
8
|
+
const desktopPackageDirectory = path.join(__dirname, "..");
|
|
9
|
+
const requireFromDesktop = createRequire(path.join(desktopPackageDirectory, "package.json"));
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
});
|
package/dist/connect-page.cjs
CHANGED
|
@@ -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
|
|
85
|
-
var
|
|
86
|
-
var
|
|
87
|
-
var
|
|
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
|
|
package/dist/connect-preload.cjs
CHANGED
|
@@ -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
|
|
113
|
-
var
|
|
114
|
-
var
|
|
115
|
-
var
|
|
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
|
|
199
|
-
if (typeof body !== "object" || body === null) return false;
|
|
200
|
-
|
|
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(
|
|
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 (
|
|
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 (!
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (!stopped) window.setTimeout(poll, delayMs);
|
|
110
|
-
}
|
|
111
|
-
})();
|
|
144
|
+
})();
|
|
145
|
+
};
|
|
146
|
+
poll();
|
|
112
147
|
};
|
|
113
|
-
window.addEventListener("pagehide",
|
|
114
|
-
|
|
148
|
+
window.addEventListener("pagehide", stopPollLoop);
|
|
149
|
+
window.addEventListener("pageshow", (event) => {
|
|
150
|
+
if (event.persisted === true) {
|
|
151
|
+
startPollLoop();
|
|
152
|
+
}
|
|
115
153
|
});
|
|
116
|
-
|
|
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.
|
|
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/
|
|
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": "
|
|
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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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 {
|
|
6
|
+
const { spawnSync } = require("node:child_process");
|
|
7
|
+
const { createRequire } = require("node:module");
|
|
16
8
|
const path = require("node:path");
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
+
});
|