@telepath-computer/television-desktop 0.1.142 → 0.1.152

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.
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ (() => {
3
+ // src/connect-error.ts
4
+ function connectErrorMessage(error) {
5
+ if (!(error instanceof Error)) return "Failed to connect";
6
+ const message = error.message;
7
+ const ipcMatch = message.match(/^Error invoking remote method '[^']+': (?:(?:Error: )+)?([\s\S]+)$/);
8
+ return ipcMatch?.[1]?.trim() || message;
9
+ }
10
+
11
+ // src/connect-page.ts
12
+ var MIN_CONNECT_MS = 500;
13
+ function bindConnectPage(api, elements) {
14
+ let busy = false;
15
+ function setBusy(next) {
16
+ busy = next;
17
+ if (next && document.activeElement instanceof HTMLElement) {
18
+ document.activeElement.blur();
19
+ }
20
+ elements.submitButton.disabled = next;
21
+ elements.submitButton.dataset.connecting = next ? "true" : "false";
22
+ elements.submitLabel.textContent = next ? "Connecting\u2026" : "Connect";
23
+ elements.serverInput.disabled = next;
24
+ elements.tokenInput.disabled = next;
25
+ }
26
+ async function waitForMinConnect(started) {
27
+ const remaining = MIN_CONNECT_MS - (Date.now() - started);
28
+ if (remaining > 0) {
29
+ await new Promise((resolve) => setTimeout(resolve, remaining));
30
+ }
31
+ }
32
+ async function runConnect(serverURL, token) {
33
+ if (busy) return;
34
+ elements.errorEl.textContent = "";
35
+ if (!serverURL.trim()) return;
36
+ setBusy(true);
37
+ const started = Date.now();
38
+ try {
39
+ const result = await api.connect(serverURL, token);
40
+ if (!result.ok) {
41
+ await waitForMinConnect(started);
42
+ elements.errorEl.textContent = result.message;
43
+ setBusy(false);
44
+ }
45
+ } catch (err) {
46
+ await waitForMinConnect(started);
47
+ elements.errorEl.textContent = connectErrorMessage(err);
48
+ setBusy(false);
49
+ }
50
+ }
51
+ const onSubmit = (event) => {
52
+ event.preventDefault();
53
+ void runConnect(elements.serverInput.value, elements.tokenInput.value);
54
+ };
55
+ elements.form.addEventListener("submit", onSubmit);
56
+ void (async () => {
57
+ const intent = await api.getConnectScreenIntent();
58
+ const saved = await api.getConnection();
59
+ if (saved) {
60
+ elements.serverInput.value = saved.serverURL;
61
+ elements.tokenInput.value = saved.token;
62
+ }
63
+ if (intent === "bootstrap" && saved) {
64
+ await runConnect(saved.serverURL, saved.token);
65
+ }
66
+ })();
67
+ return () => elements.form.removeEventListener("submit", onSubmit);
68
+ }
69
+ function initConnectPage(api) {
70
+ const form = document.getElementById("connect-form");
71
+ const errorEl = document.getElementById("error");
72
+ const submitButton = document.getElementById("submit");
73
+ const serverInput = document.getElementById("serverURL");
74
+ const tokenInput = document.getElementById("token");
75
+ const submitLabel = submitButton?.querySelector(".button-label");
76
+ if (!(form instanceof HTMLFormElement)) throw new Error("connect form missing");
77
+ if (!(errorEl instanceof HTMLElement)) throw new Error("connect error element missing");
78
+ if (!(submitButton instanceof HTMLButtonElement)) throw new Error("connect submit button missing");
79
+ if (!(serverInput instanceof HTMLInputElement)) throw new Error("connect server input missing");
80
+ if (!(tokenInput instanceof HTMLInputElement)) throw new Error("connect token input missing");
81
+ if (!(submitLabel instanceof HTMLElement)) throw new Error("connect submit label missing");
82
+ bindConnectPage(api, { form, errorEl, submitButton, submitLabel, serverInput, tokenInput });
83
+ }
84
+ if (typeof window !== "undefined" && window.television) {
85
+ initConnectPage(window.television);
86
+ }
87
+ })();
@@ -2,8 +2,15 @@
2
2
 
3
3
  // src/connect-preload.ts
4
4
  var import_electron = require("electron");
5
+
6
+ // src/connect-screen.ts
7
+ var GET_CONNECT_SCREEN_INTENT_CHANNEL = "television:get-connect-screen-intent";
8
+
9
+ // src/connect-preload.ts
5
10
  if (window.location.protocol === "file:") {
6
11
  import_electron.contextBridge.exposeInMainWorld("television", {
7
- setConnection: (serverURL, token) => import_electron.ipcRenderer.invoke("television:set-connection", { serverURL, token })
12
+ getConnectScreenIntent: () => import_electron.ipcRenderer.invoke(GET_CONNECT_SCREEN_INTENT_CHANNEL),
13
+ getConnection: () => import_electron.ipcRenderer.invoke("television:get-connection"),
14
+ connect: (serverURL, token) => import_electron.ipcRenderer.invoke("television:connect", { serverURL, token })
8
15
  });
9
16
  }
package/dist/connect.html CHANGED
@@ -2,7 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
- <meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'" />
5
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'unsafe-inline'; script-src 'self' 'unsafe-inline'" />
6
6
  <title>Television</title>
7
7
  <style>
8
8
  :root {
@@ -61,25 +61,66 @@
61
61
  input:focus {
62
62
  border-color: #fff;
63
63
  }
64
+ input:disabled {
65
+ opacity: 0.6;
66
+ border-color: #2a2a2a;
67
+ }
64
68
  button {
65
69
  margin-top: 6px;
66
70
  background: #fff;
67
71
  color: #000;
68
72
  border: none;
69
- padding: 10px 12px;
73
+ padding: 0 16px;
70
74
  font-size: 14px;
71
75
  font-weight: 500;
72
76
  font-family: inherit;
73
77
  border-radius: 6px;
74
78
  cursor: default;
79
+ position: relative;
80
+ display: flex;
81
+ align-items: center;
82
+ justify-content: center;
83
+ height: 40px;
84
+ box-sizing: border-box;
75
85
  }
76
- button:disabled {
86
+ button:disabled:not([data-connecting="true"]) {
77
87
  opacity: 0.5;
78
88
  }
89
+ button[data-connecting="true"] {
90
+ opacity: 0.85;
91
+ }
92
+ .button-label {
93
+ line-height: 1;
94
+ }
95
+ .button-spinner {
96
+ position: absolute;
97
+ left: 16px;
98
+ top: 0;
99
+ bottom: 0;
100
+ width: 14px;
101
+ height: 14px;
102
+ margin-block: auto;
103
+ box-sizing: border-box;
104
+ border: 2px solid rgba(0, 0, 0, 0.2);
105
+ border-top-color: #000;
106
+ border-radius: 50%;
107
+ visibility: hidden;
108
+ animation: connect-spin 0.7s linear infinite;
109
+ }
110
+ button[data-connecting="true"] .button-spinner {
111
+ visibility: visible;
112
+ }
113
+ @keyframes connect-spin {
114
+ to {
115
+ transform: rotate(360deg);
116
+ }
117
+ }
79
118
  .error {
80
119
  color: #ff6b6b;
81
120
  font-size: 12px;
82
- min-height: 1em;
121
+ line-height: 18px;
122
+ height: 36px;
123
+ overflow: hidden;
83
124
  }
84
125
  </style>
85
126
  </head>
@@ -89,40 +130,19 @@
89
130
  <form id="connect-form">
90
131
  <label>
91
132
  Server URL
92
- <input id="serverURL" type="url" placeholder="https://example.com" required autofocus />
133
+ <input id="serverURL" type="text" placeholder="https://example.com" required autofocus />
93
134
  </label>
94
135
  <label>
95
136
  Token <span style="text-transform: none; letter-spacing: 0; color: #555;">(leave blank for no-auth servers)</span>
96
137
  <input id="token" type="password" />
97
138
  </label>
98
139
  <div class="error" id="error"></div>
99
- <button type="submit">Connect</button>
140
+ <button type="submit" id="submit">
141
+ <span class="button-spinner" aria-hidden="true"></span>
142
+ <span class="button-label">Connect</span>
143
+ </button>
100
144
  </form>
101
145
  </main>
102
- <script>
103
- const form = document.getElementById("connect-form");
104
- const errorEl = document.getElementById("error");
105
- const submitButton = form.querySelector("button");
106
- form.addEventListener("submit", async (event) => {
107
- event.preventDefault();
108
- errorEl.textContent = "";
109
- const serverURL = document.getElementById("serverURL").value.trim();
110
- const token = document.getElementById("token").value.trim();
111
- if (!serverURL) return;
112
- try {
113
- new URL(serverURL);
114
- } catch {
115
- errorEl.textContent = "Invalid URL";
116
- return;
117
- }
118
- submitButton.disabled = true;
119
- try {
120
- await window.television.setConnection(serverURL, token);
121
- } catch (err) {
122
- errorEl.textContent = err instanceof Error ? err.message : "Failed to connect";
123
- submitButton.disabled = false;
124
- }
125
- });
126
- </script>
146
+ <script src="connect-page.cjs"></script>
127
147
  </body>
128
148
  </html>
package/dist/electron.cjs CHANGED
@@ -36,6 +36,93 @@ 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
+ // src/connect-url.ts
40
+ var ConnectURLError = class extends Error {
41
+ constructor(message = "Enter a valid http or https URL") {
42
+ super(message);
43
+ this.name = "ConnectURLError";
44
+ }
45
+ };
46
+ function normalizeConnectURL(input) {
47
+ const trimmed = input.trim();
48
+ if (!trimmed) throw new ConnectURLError();
49
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) && !/^https?:\/\//i.test(trimmed)) {
50
+ throw new ConnectURLError();
51
+ }
52
+ const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
53
+ let url;
54
+ try {
55
+ url = new URL(withScheme);
56
+ } catch {
57
+ throw new ConnectURLError();
58
+ }
59
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
60
+ throw new ConnectURLError();
61
+ }
62
+ return url.toString().replace(/\/$/, "");
63
+ }
64
+ function buildRemoteURL(serverURL, token) {
65
+ const url = new URL(serverURL);
66
+ url.searchParams.set("mode", "electron");
67
+ if (token) url.searchParams.set("token", token);
68
+ return url.toString();
69
+ }
70
+
71
+ // src/connect-preflight.ts
72
+ var HTTP_UNAUTHORIZED = 401;
73
+ var DEFAULT_PREFLIGHT_TIMEOUT_MS = 8e3;
74
+ var NOT_TV_SERVER_MESSAGE = "This URL doesn't seem to be a Television server \u2014 please check it.";
75
+ function unreachable(serverURL) {
76
+ return {
77
+ ok: false,
78
+ code: "unreachable",
79
+ message: `Couldn't reach ${serverURL} \u2014 is the server running?`
80
+ };
81
+ }
82
+ function notTVServer() {
83
+ return { ok: false, code: "not-tv-server", message: NOT_TV_SERVER_MESSAGE };
84
+ }
85
+ function isDisplayResponse(body) {
86
+ if (typeof body !== "object" || body === null) return false;
87
+ const candidate = body;
88
+ return (typeof candidate.activeScreenID === "string" || candidate.activeScreenID === null) && (typeof candidate.activeThemeName === "string" || candidate.activeThemeName === null) && typeof candidate.acpEnabled === "boolean";
89
+ }
90
+ async function preflightConnection(options) {
91
+ const fetchImpl = options.fetchImpl ?? fetch;
92
+ const probeURL = new URL(options.probePath ?? "/display", options.serverURL).toString();
93
+ const headers = {};
94
+ if (options.token) headers.Authorization = `Bearer ${options.token}`;
95
+ let response;
96
+ try {
97
+ response = await fetchImpl(probeURL, {
98
+ method: "GET",
99
+ headers,
100
+ signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS)
101
+ });
102
+ } catch {
103
+ return unreachable(options.serverURL);
104
+ }
105
+ if (response.status === HTTP_UNAUTHORIZED) {
106
+ if (!options.token) {
107
+ return { ok: false, code: "auth-required", message: "This server requires a token" };
108
+ }
109
+ return { ok: false, code: "auth-rejected", message: "Token rejected" };
110
+ }
111
+ if (!response.ok) {
112
+ return notTVServer();
113
+ }
114
+ let body;
115
+ try {
116
+ body = await response.json();
117
+ } catch {
118
+ return notTVServer();
119
+ }
120
+ if (!isDisplayResponse(body)) {
121
+ return notTVServer();
122
+ }
123
+ return { ok: true };
124
+ }
125
+
39
126
  // src/connection-store.ts
40
127
  var import_electron = require("electron");
41
128
  var import_node_fs = require("node:fs");
@@ -55,26 +142,28 @@ function loadConnection() {
55
142
  }
56
143
  }
57
144
  function saveConnection(connection) {
58
- (0, import_node_fs.writeFileSync)(storePath(), JSON.stringify(connection, null, 2), "utf8");
145
+ const file = storePath();
146
+ const tmp = `${file}.tmp`;
147
+ (0, import_node_fs.writeFileSync)(tmp, JSON.stringify(connection, null, 2), "utf8");
148
+ (0, import_node_fs.renameSync)(tmp, file);
59
149
  }
60
150
 
151
+ // src/connect-screen.ts
152
+ var GET_CONNECT_SCREEN_INTENT_CHANNEL = "television:get-connect-screen-intent";
153
+
61
154
  // src/index.ts
62
155
  import_electron2.app.setName("Television");
63
156
  var WINDOW_WIDTH = 1400;
64
157
  var WINDOW_HEIGHT = 1e3;
65
158
  var TRAFFIC_LIGHT_POSITION = { x: 12, y: 12 };
66
- var SET_CONNECTION_CHANNEL = "television:set-connection";
159
+ var GET_CONNECTION_CHANNEL = "television:get-connection";
160
+ var CONNECT_CHANNEL = "television:connect";
67
161
  var TEST_EXTERNAL_OPEN_CHANNEL = "television:test:external-open";
68
162
  var TEST_FIXTURE_FLAG = "--test-fixture";
163
+ var ERR_ABORTED = -3;
69
164
  if (process.env.TV_ELECTRON_CHROMIUM_LOGS !== "1") {
70
165
  import_electron2.app.commandLine?.appendSwitch("log-level", "3");
71
166
  }
72
- function buildRemoteURL({ serverURL, token }) {
73
- const url = new URL(serverURL);
74
- url.searchParams.set("mode", "electron");
75
- if (token) url.searchParams.set("token", token);
76
- return url.toString();
77
- }
78
167
  function readTestFixtureURL(argv) {
79
168
  const idx = argv.indexOf(TEST_FIXTURE_FLAG);
80
169
  if (idx === -1 || idx === argv.length - 1) return null;
@@ -93,6 +182,7 @@ function recordExternalOpenForTest(url) {
93
182
  }
94
183
  var App = class {
95
184
  window = null;
185
+ connectScreenIntent = "manual";
96
186
  async start() {
97
187
  await import_electron2.app.whenReady();
98
188
  if (process.platform === "darwin" && import_electron2.app.dock) {
@@ -100,10 +190,9 @@ var App = class {
100
190
  }
101
191
  this.installMenu();
102
192
  this.installWindowOpenHandler();
103
- import_electron2.ipcMain.handle(SET_CONNECTION_CHANNEL, async (_event, connection) => {
104
- saveConnection(connection);
105
- await this.loadRemote(connection);
106
- });
193
+ import_electron2.ipcMain.handle(GET_CONNECTION_CHANNEL, async () => loadConnection());
194
+ import_electron2.ipcMain.handle(GET_CONNECT_SCREEN_INTENT_CHANNEL, async () => this.connectScreenIntent);
195
+ import_electron2.ipcMain.handle(CONNECT_CHANNEL, async (_event, connection) => this.tryConnect(connection));
107
196
  await this.createWindow();
108
197
  import_electron2.app.on("window-all-closed", () => {
109
198
  if (process.platform !== "darwin") import_electron2.app.quit();
@@ -132,6 +221,12 @@ var App = class {
132
221
  webPreferences.preload = import_node_path2.default.join(__dirname, "webview-bridge-preload.cjs");
133
222
  webPreferences.contextIsolation = false;
134
223
  });
224
+ this.window.webContents.on("did-fail-load", (_event, code, _description, validatedURL, isMainFrame) => {
225
+ if (!isMainFrame) return;
226
+ if (code === ERR_ABORTED) return;
227
+ if (validatedURL.startsWith("file:")) return;
228
+ void this.loadConnectScreen("manual");
229
+ });
135
230
  this.window.once("ready-to-show", () => this.window?.show());
136
231
  this.window.on("closed", () => {
137
232
  this.window = null;
@@ -141,20 +236,48 @@ var App = class {
141
236
  await this.window.loadURL(testFixtureURL);
142
237
  return;
143
238
  }
144
- const connection = loadConnection();
145
- if (connection) {
146
- await this.loadRemote(connection);
147
- } else {
148
- await this.loadConnectScreen();
149
- }
239
+ await this.loadConnectScreen("bootstrap");
150
240
  }
151
- async loadConnectScreen() {
241
+ /** @param intent bootstrap = app open / retry saved connection; manual = user chose connect screen */
242
+ async loadConnectScreen(intent = "manual") {
152
243
  if (!this.window) return;
244
+ this.connectScreenIntent = intent;
153
245
  await this.window.loadFile(import_node_path2.default.join(__dirname, "connect.html"));
154
246
  }
247
+ async tryConnect(raw) {
248
+ const submitted = { serverURL: raw.serverURL, token: raw.token ?? "" };
249
+ try {
250
+ saveConnection(submitted);
251
+ } catch (error) {
252
+ const message = error instanceof Error ? error.message : "Failed to connect";
253
+ return { ok: false, message };
254
+ }
255
+ let serverURL;
256
+ try {
257
+ serverURL = normalizeConnectURL(raw.serverURL);
258
+ } catch (error) {
259
+ const message = error instanceof ConnectURLError ? error.message : new ConnectURLError().message;
260
+ return { ok: false, message };
261
+ }
262
+ const connection = { serverURL, token: submitted.token };
263
+ try {
264
+ const preflight = await preflightConnection({
265
+ serverURL: connection.serverURL,
266
+ token: connection.token
267
+ });
268
+ if (!preflight.ok) {
269
+ return { ok: false, message: preflight.message };
270
+ }
271
+ await this.loadRemote(connection);
272
+ return { ok: true };
273
+ } catch (error) {
274
+ const message = error instanceof Error ? error.message : "Failed to connect";
275
+ return { ok: false, message };
276
+ }
277
+ }
155
278
  async loadRemote(connection) {
156
279
  if (!this.window) return;
157
- await this.window.loadURL(buildRemoteURL(connection));
280
+ await this.window.loadURL(buildRemoteURL(connection.serverURL, connection.token));
158
281
  }
159
282
  installWindowOpenHandler() {
160
283
  import_electron2.app.on("web-contents-created", (_event, contents) => {
@@ -175,7 +298,7 @@ var App = class {
175
298
  const connectItem = {
176
299
  label: "Connect to server\u2026",
177
300
  accelerator: "CmdOrCtrl+,",
178
- click: () => void this.loadConnectScreen()
301
+ click: () => void this.loadConnectScreen("manual")
179
302
  };
180
303
  const template = [
181
304
  ...isMac ? [
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@telepath-computer/television-desktop",
3
3
  "productName": "Television",
4
- "version": "0.1.142",
4
+ "version": "0.1.152",
5
5
  "type": "module",
6
6
  "main": "dist/electron.cjs",
7
7
  "bin": {
@@ -19,9 +19,7 @@
19
19
  "scripts": {
20
20
  "build": "node build.mjs",
21
21
  "prepublishOnly": "node build.mjs",
22
- "type-check": "tsc -p tsconfig.json --noEmit",
23
- "test": "vitest run --config vitest.config.ts",
24
- "test:e2e": "node ../../scripts/run-electron-e2e.mjs"
22
+ "type-check": "tsc -p tsconfig.json --noEmit"
25
23
  },
26
24
  "dependencies": {
27
25
  "electron": "35.7.5"