@telepath-computer/television-desktop 0.1.112 → 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.
- package/README.md +3 -2
- package/assets/icon.icns +0 -0
- package/assets/icon.png +0 -0
- package/dist/connect-page.cjs +87 -0
- package/dist/connect-preload.cjs +8 -1
- package/dist/connect.html +52 -32
- package/dist/electron.cjs +174 -20
- package/dist/webview-bridge-preload.cjs +62 -41
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,8 +14,9 @@ npm i -g @telepath-computer/television-desktop
|
|
|
14
14
|
tv-desktop
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
A connect window opens; point it at a running Television server
|
|
18
|
-
token
|
|
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.
|
|
19
20
|
|
|
20
21
|
## macOS notes
|
|
21
22
|
|
package/assets/icon.icns
CHANGED
|
Binary file
|
package/assets/icon.png
CHANGED
|
Binary file
|
|
@@ -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
|
+
})();
|
package/dist/connect-preload.cjs
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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="
|
|
133
|
+
<input id="serverURL" type="text" placeholder="https://example.com" required autofocus />
|
|
93
134
|
</label>
|
|
94
135
|
<label>
|
|
95
|
-
Token <span style="text-transform: none; letter-spacing: 0; color: #555;">(leave blank for
|
|
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">
|
|
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,39 +142,57 @@ function loadConnection() {
|
|
|
55
142
|
}
|
|
56
143
|
}
|
|
57
144
|
function saveConnection(connection) {
|
|
58
|
-
|
|
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
|
|
159
|
+
var GET_CONNECTION_CHANNEL = "television:get-connection";
|
|
160
|
+
var CONNECT_CHANNEL = "television:connect";
|
|
161
|
+
var TEST_EXTERNAL_OPEN_CHANNEL = "television:test:external-open";
|
|
67
162
|
var TEST_FIXTURE_FLAG = "--test-fixture";
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (token) url.searchParams.set("token", token);
|
|
72
|
-
return url.toString();
|
|
163
|
+
var ERR_ABORTED = -3;
|
|
164
|
+
if (process.env.TV_ELECTRON_CHROMIUM_LOGS !== "1") {
|
|
165
|
+
import_electron2.app.commandLine?.appendSwitch("log-level", "3");
|
|
73
166
|
}
|
|
74
167
|
function readTestFixtureURL(argv) {
|
|
75
168
|
const idx = argv.indexOf(TEST_FIXTURE_FLAG);
|
|
76
169
|
if (idx === -1 || idx === argv.length - 1) return null;
|
|
77
170
|
return argv[idx + 1] ?? null;
|
|
78
171
|
}
|
|
172
|
+
function isExternalOpenURL(url) {
|
|
173
|
+
return url.startsWith("http://") || url.startsWith("https://");
|
|
174
|
+
}
|
|
175
|
+
function recordExternalOpenForTest(url) {
|
|
176
|
+
const globalState = globalThis;
|
|
177
|
+
const log = globalState.__televisionExternalOpenLog ??= [];
|
|
178
|
+
log.push(url);
|
|
179
|
+
for (const window of import_electron2.BrowserWindow.getAllWindows()) {
|
|
180
|
+
window.webContents.send(TEST_EXTERNAL_OPEN_CHANNEL, url);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
79
183
|
var App = class {
|
|
80
184
|
window = null;
|
|
185
|
+
connectScreenIntent = "manual";
|
|
81
186
|
async start() {
|
|
82
187
|
await import_electron2.app.whenReady();
|
|
83
188
|
if (process.platform === "darwin" && import_electron2.app.dock) {
|
|
84
189
|
import_electron2.app.dock.setIcon(import_node_path2.default.join(__dirname, "..", "assets", "icon.png"));
|
|
85
190
|
}
|
|
86
191
|
this.installMenu();
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
192
|
+
this.installWindowOpenHandler();
|
|
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));
|
|
91
196
|
await this.createWindow();
|
|
92
197
|
import_electron2.app.on("window-all-closed", () => {
|
|
93
198
|
if (process.platform !== "darwin") import_electron2.app.quit();
|
|
@@ -114,6 +219,13 @@ var App = class {
|
|
|
114
219
|
});
|
|
115
220
|
this.window.webContents.on("will-attach-webview", (_event, webPreferences) => {
|
|
116
221
|
webPreferences.preload = import_node_path2.default.join(__dirname, "webview-bridge-preload.cjs");
|
|
222
|
+
webPreferences.contextIsolation = false;
|
|
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");
|
|
117
229
|
});
|
|
118
230
|
this.window.once("ready-to-show", () => this.window?.show());
|
|
119
231
|
this.window.on("closed", () => {
|
|
@@ -124,27 +236,69 @@ var App = class {
|
|
|
124
236
|
await this.window.loadURL(testFixtureURL);
|
|
125
237
|
return;
|
|
126
238
|
}
|
|
127
|
-
|
|
128
|
-
if (connection) {
|
|
129
|
-
await this.loadRemote(connection);
|
|
130
|
-
} else {
|
|
131
|
-
await this.loadConnectScreen();
|
|
132
|
-
}
|
|
239
|
+
await this.loadConnectScreen("bootstrap");
|
|
133
240
|
}
|
|
134
|
-
|
|
241
|
+
/** @param intent bootstrap = app open / retry saved connection; manual = user chose connect screen */
|
|
242
|
+
async loadConnectScreen(intent = "manual") {
|
|
135
243
|
if (!this.window) return;
|
|
244
|
+
this.connectScreenIntent = intent;
|
|
136
245
|
await this.window.loadFile(import_node_path2.default.join(__dirname, "connect.html"));
|
|
137
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
|
+
}
|
|
138
278
|
async loadRemote(connection) {
|
|
139
279
|
if (!this.window) return;
|
|
140
|
-
await this.window.loadURL(buildRemoteURL(connection));
|
|
280
|
+
await this.window.loadURL(buildRemoteURL(connection.serverURL, connection.token));
|
|
281
|
+
}
|
|
282
|
+
installWindowOpenHandler() {
|
|
283
|
+
import_electron2.app.on("web-contents-created", (_event, contents) => {
|
|
284
|
+
contents.setWindowOpenHandler(({ url }) => {
|
|
285
|
+
if (isExternalOpenURL(url)) {
|
|
286
|
+
if (process.env.TV_TEST_MODE === "true") {
|
|
287
|
+
recordExternalOpenForTest(url);
|
|
288
|
+
} else {
|
|
289
|
+
void import_electron2.shell.openExternal(url);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return { action: "deny" };
|
|
293
|
+
});
|
|
294
|
+
});
|
|
141
295
|
}
|
|
142
296
|
installMenu() {
|
|
143
297
|
const isMac = process.platform === "darwin";
|
|
144
298
|
const connectItem = {
|
|
145
299
|
label: "Connect to server\u2026",
|
|
146
300
|
accelerator: "CmdOrCtrl+,",
|
|
147
|
-
click: () => void this.loadConnectScreen()
|
|
301
|
+
click: () => void this.loadConnectScreen("manual")
|
|
148
302
|
};
|
|
149
303
|
const template = [
|
|
150
304
|
...isMac ? [
|
|
@@ -3,55 +3,76 @@
|
|
|
3
3
|
// src/webview-bridge-preload.ts
|
|
4
4
|
var import_electron = require("electron");
|
|
5
5
|
var BRIDGE_CHANNEL = "television-artifact-bridge";
|
|
6
|
-
var
|
|
7
|
-
|
|
6
|
+
var contentBridge = {
|
|
7
|
+
postToHost(message) {
|
|
8
|
+
import_electron.ipcRenderer.sendToHost(BRIDGE_CHANNEL, message);
|
|
9
|
+
},
|
|
10
|
+
onHostMessage(callback) {
|
|
11
|
+
const listener = (_event, message) => {
|
|
12
|
+
callback(message);
|
|
13
|
+
};
|
|
14
|
+
import_electron.ipcRenderer.on(BRIDGE_CHANNEL, listener);
|
|
15
|
+
return () => {
|
|
16
|
+
import_electron.ipcRenderer.removeListener(BRIDGE_CHANNEL, listener);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
if (process.contextIsolated) {
|
|
21
|
+
import_electron.contextBridge.exposeInMainWorld("__televisionContentBridge", contentBridge);
|
|
22
|
+
} else {
|
|
23
|
+
window.__televisionContentBridge = contentBridge;
|
|
24
|
+
}
|
|
25
|
+
function isTextEditingTarget(target) {
|
|
26
|
+
if (!(target instanceof Element)) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const editable = target.closest(
|
|
30
|
+
"input, textarea, select, [contenteditable], [role='textbox'], [role='searchbox'], [role='combobox'], [role='spinbutton'], [role='slider']"
|
|
31
|
+
);
|
|
32
|
+
if (!editable) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (editable.hasAttribute("contenteditable")) {
|
|
36
|
+
return editable.getAttribute("contenteditable")?.toLowerCase() !== "false";
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
window.addEventListener(
|
|
41
|
+
"keydown",
|
|
42
|
+
(event) => {
|
|
43
|
+
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (isTextEditingTarget(event.target)) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
event.preventDefault();
|
|
53
|
+
import_electron.ipcRenderer.sendToHost(BRIDGE_CHANNEL, {
|
|
54
|
+
type: "filmstrip-key",
|
|
55
|
+
direction: event.key === "ArrowRight" ? 1 : -1
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
{ capture: true }
|
|
59
|
+
);
|
|
8
60
|
window.addEventListener(
|
|
9
61
|
"wheel",
|
|
10
62
|
(event) => {
|
|
11
|
-
|
|
12
|
-
|
|
63
|
+
const usingShiftFallback = event.shiftKey && event.deltaX === 0 && event.deltaY !== 0;
|
|
64
|
+
const horizontalDelta = usingShiftFallback ? event.deltaY : event.deltaX;
|
|
65
|
+
const horizontalMagnitude = Math.abs(horizontalDelta);
|
|
66
|
+
const verticalMagnitude = usingShiftFallback ? 0 : Math.abs(event.deltaY);
|
|
67
|
+
if (horizontalMagnitude <= verticalMagnitude) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
13
70
|
const payload = {
|
|
14
71
|
type: "wheel",
|
|
15
|
-
deltaX:
|
|
16
|
-
deltaY: event.deltaY,
|
|
72
|
+
deltaX: horizontalDelta,
|
|
17
73
|
shiftKey: event.shiftKey
|
|
18
74
|
};
|
|
19
75
|
import_electron.ipcRenderer.sendToHost(BRIDGE_CHANNEL, payload);
|
|
20
|
-
event.preventDefault();
|
|
21
76
|
},
|
|
22
77
|
{ passive: false }
|
|
23
78
|
);
|
|
24
|
-
import_electron.ipcRenderer.on(BRIDGE_CHANNEL, (_event, payload) => {
|
|
25
|
-
if (payload?.type !== "scroll") return;
|
|
26
|
-
const deltaX = payload.deltaX ?? 0;
|
|
27
|
-
const deltaY = payload.deltaY ?? 0;
|
|
28
|
-
const target = resolveScrollTarget(lastWheelClientX, lastWheelClientY, deltaX, deltaY);
|
|
29
|
-
target.scrollBy(deltaX, deltaY);
|
|
30
|
-
});
|
|
31
|
-
function resolveScrollTarget(x, y, deltaX, deltaY) {
|
|
32
|
-
const fallback = document.scrollingElement ?? document.documentElement;
|
|
33
|
-
if (typeof document.elementFromPoint !== "function") return fallback;
|
|
34
|
-
let node = document.elementFromPoint(x, y);
|
|
35
|
-
while (node && node !== document.documentElement) {
|
|
36
|
-
if (canScroll(node, deltaX, deltaY)) return node;
|
|
37
|
-
node = node.parentElement;
|
|
38
|
-
}
|
|
39
|
-
return fallback;
|
|
40
|
-
}
|
|
41
|
-
function canScroll(node, deltaX, deltaY) {
|
|
42
|
-
const style = getComputedStyle(node);
|
|
43
|
-
if (deltaY !== 0) {
|
|
44
|
-
if (isScrollableOverflow(style.overflowY) && node.scrollHeight > node.clientHeight) {
|
|
45
|
-
return true;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
if (deltaX !== 0) {
|
|
49
|
-
if (isScrollableOverflow(style.overflowX) && node.scrollWidth > node.clientWidth) {
|
|
50
|
-
return true;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
function isScrollableOverflow(value) {
|
|
56
|
-
return value === "auto" || value === "scroll" || value === "overlay";
|
|
57
|
-
}
|