Skip to content

Commit c36dee0

Browse files
committed
feat(desktop): open HTTP assets from client protocol URLs
Route jms2 HTTP/HTTPS payloads into the built-in web proxy, keep Core web_proxy as a client method on web, and add a download-client shortcut on the connection setup pane.
1 parent c488013 commit c36dee0

20 files changed

Lines changed: 317 additions & 18 deletions

‎electron/src/apps/local-app-launcher.ts‎

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,9 @@
11
import { spawn } from "node:child_process";
22
import { mkdir, stat, writeFile } from "node:fs/promises";
33
import path from "node:path";
4+
import { decodeClientProtocolPayload as decodePayload } from "../shared/client-protocol";
45
import { electronLog } from "../shared/debug-log";
56

6-
function decodePayload(raw) {
7-
const value = String(raw || "");
8-
const encoded = value.startsWith("jms2://") ? value.slice(7) : "";
9-
if (!encoded) throw new Error("invalid local client URL scheme");
10-
try {
11-
return JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
12-
} catch (error) {
13-
throw new Error(`decode local client payload failed: ${error instanceof Error ? error.message : error}`);
14-
}
15-
}
16-
177
function sanitizedName(raw) {
188
let decoded = String(raw || "");
199
try {

‎electron/src/desktop/main.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,12 @@ import { FfmpegPluginManager } from "../replay/ffmpeg-plugin";
3434
import { OfflineRecordingStore } from "../replay/offline-recordings";
3535
import { ReplayTranscoder } from "../replay/transcoder";
3636
import { readableToWebBody } from "../shared/bytes";
37-
import { findClientProtocolUrl, normalizeClientProtocolUrl } from "../shared/client-protocol";
37+
import {
38+
decodeClientProtocolPayload,
39+
findClientProtocolUrl,
40+
isWebAssetClientProtocolPayload,
41+
normalizeClientProtocolUrl
42+
} from "../shared/client-protocol";
3843
import {
3944
activateDebugLogService,
4045
DebugLogService,
@@ -1291,6 +1296,7 @@ async function handleInvoke(event, request) {
12911296
return applicationConfig.updateCustomTerminal({ ...args, path: resolveExecutablePath(args.path) });
12921297
}
12931298
if (command === "pull_up") return withIpcErrorLog("pull_up", () => localApplicationLauncher.launch(args.url));
1299+
if (command === "take_web_protocol_payloads") return takePendingWebProtocolPayloads();
12941300
if (command === "list_system_fonts") return listSystemFonts();
12951301
if (command === "cancel_transcode") return replayTranscoder.cancelCurrent(labelForWindow(win));
12961302
if (command === "transcode_replays") {
@@ -1409,8 +1415,21 @@ interface PendingProtocolUrl {
14091415
}
14101416

14111417
const pendingProtocolUrls: PendingProtocolUrl[] = [];
1418+
const pendingWebProtocolPayloads: Array<{
1419+
url: string;
1420+
payload: ReturnType<typeof decodeClientProtocolPayload>;
1421+
}> = [];
14121422
let startupFinished = false;
14131423

1424+
function queueWebProtocolPayload(url: string, payload: ReturnType<typeof decodeClientProtocolPayload>) {
1425+
if (pendingWebProtocolPayloads.some((pending) => pending.url === url)) return;
1426+
pendingWebProtocolPayloads.push({ url, payload });
1427+
}
1428+
1429+
function takePendingWebProtocolPayloads() {
1430+
return pendingWebProtocolPayloads.splice(0).map((pending) => pending.payload);
1431+
}
1432+
14141433
function describeProtocolUrl(rawUrl) {
14151434
const value = String(rawUrl || "");
14161435
if (isOAuthCallbackUrl(value)) return "auth-callback";
@@ -1420,6 +1439,12 @@ function describeProtocolUrl(rawUrl) {
14201439

14211440
async function processIncomingProtocolUrl(value: string) {
14221441
if (authService.handleCallback(value) || authService.isOAuthCallbackUrl(value)) return false;
1442+
const payload = decodeClientProtocolPayload(value);
1443+
if (isWebAssetClientProtocolPayload(payload)) {
1444+
queueWebProtocolPayload(value, payload);
1445+
if (startupFinished) sendMainWindowEvent("web-protocol-url", null);
1446+
return false;
1447+
}
14231448
await localApplicationLauncher.launch(value);
14241449
return true;
14251450
}

‎electron/src/shared/client-protocol.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,36 @@ export function normalizeClientProtocolUrl(raw: unknown): string | undefined {
2222
if (/^jms2:\/\//i.test(value)) return `${CLIENT_PROTOCOL}:${value.slice(value.indexOf(":") + 1)}`;
2323
}
2424

25+
export interface ClientProtocolPayload {
26+
protocol?: string;
27+
asset?: { id?: unknown; [key: string]: unknown };
28+
token?: { id?: unknown; protocol?: string; [key: string]: unknown };
29+
file?: { content?: unknown; [key: string]: unknown };
30+
endpoint?: { host?: unknown; port?: unknown; [key: string]: unknown };
31+
[key: string]: unknown;
32+
}
33+
34+
export function decodeClientProtocolPayload(raw: unknown): ClientProtocolPayload {
35+
const value = normalizeClientProtocolUrl(raw);
36+
const encoded = value?.startsWith(`${CLIENT_PROTOCOL}://`) ? value.slice(`${CLIENT_PROTOCOL}://`.length) : "";
37+
if (!encoded) throw new Error("invalid local client URL scheme");
38+
try {
39+
const payload: unknown = JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
40+
if (!payload || Array.isArray(payload) || typeof payload !== "object") throw new Error("payload must be an object");
41+
return payload as ClientProtocolPayload;
42+
} catch (error) {
43+
throw new Error(`decode local client payload failed: ${error instanceof Error ? error.message : error}`);
44+
}
45+
}
46+
47+
export function isWebAssetClientProtocolPayload(payload: ClientProtocolPayload) {
48+
return (
49+
["http", "https"].includes(String(payload.protocol || "").toLowerCase()) &&
50+
typeof payload.asset?.id === "string" &&
51+
Boolean(payload.asset.id.trim())
52+
);
53+
}
54+
2555
export function findClientProtocolUrl(argv: string[], additionalData?: unknown): string | undefined {
2656
if (additionalData && typeof additionalData === "object" && "protocolUrl" in additionalData) {
2757
const forwarded = normalizeClientProtocolUrl(additionalData.protocolUrl);

‎electron/tests/client-protocol.test.ts‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,46 @@ import test from "node:test";
99
import ts from "typescript";
1010
import {
1111
CLIENT_AUTH_CALLBACK,
12+
decodeClientProtocolPayload,
1213
findClientProtocolUrl,
14+
isWebAssetClientProtocolPayload,
1315
normalizeClientProtocolUrl,
1416
registerClientProtocol
1517
} from "../src/shared/client-protocol.ts";
1618

19+
function loadTestDependency(require: NodeJS.Require, name: string) {
20+
switch (name) {
21+
case "../../../ui/utils/apiError":
22+
return require("../../../ui/utils/apiError");
23+
case "../package.json":
24+
return require("../package.json");
25+
case "../shared/client-protocol":
26+
return require("../shared/client-protocol");
27+
case "../shared/debug-log":
28+
return require("../shared/debug-log");
29+
case "../shared/url":
30+
return require("../shared/url");
31+
case "./oauth-callback":
32+
return require("./oauth-callback");
33+
case "./shared/product-name":
34+
return require("./shared/product-name");
35+
case "node:child_process":
36+
return require("node:child_process");
37+
case "node:crypto":
38+
return require("node:crypto");
39+
case "node:events":
40+
return require("node:events");
41+
case "node:fs/promises":
42+
return require("node:fs/promises");
43+
case "node:http":
44+
return require("node:http");
45+
case "node:path":
46+
return require("node:path");
47+
default:
48+
throw new Error(`Unexpected test dependency: ${name}`);
49+
}
50+
}
51+
1752
// Execute the real entry/service code with Electron replaced; no desktop or OS
1853
// registration is touched by these Windows lifecycle checks on other platforms.
1954
function loadWithElectronMocks(relativePath: string, replacements: Record<string, unknown>, runtime = process) {
@@ -25,7 +60,8 @@ function loadWithElectronMocks(relativePath: string, replacements: Record<string
2560
const exports: Record<string, any> = {};
2661
runInNewContext(output.outputText, {
2762
exports,
28-
require: (name: string) => (Object.hasOwn(replacements, name) ? replacements[name] : require(name)),
63+
require: (name: string) =>
64+
Object.hasOwn(replacements, name) ? replacements[name] : loadTestDependency(require, name),
2965
process: runtime,
3066
console,
3167
Buffer,
@@ -141,6 +177,20 @@ test("extracts Windows cold-start and forwarded URLs without changing their payl
141177
assert.equal(normalizeClientProtocolUrl("jms2%3A%2F%2Fbad%ZZ"), undefined);
142178
});
143179

180+
test("routes only HTTP and HTTPS asset payloads to the built-in web proxy", () => {
181+
const encode = (payload: unknown) => `jms2://${Buffer.from(JSON.stringify(payload)).toString("base64")}`;
182+
const http = decodeClientProtocolPayload(
183+
encode({ protocol: "http", asset: { id: "asset-1" }, token: { id: "token" } })
184+
);
185+
const https = decodeClientProtocolPayload(encode({ protocol: "HTTPS", asset: { id: "asset-2" } }));
186+
const ssh = decodeClientProtocolPayload(encode({ protocol: "ssh", asset: { id: "asset-3" } }));
187+
188+
assert.equal(isWebAssetClientProtocolPayload(http), true);
189+
assert.equal(isWebAssetClientProtocolPayload(https), true);
190+
assert.equal(isWebAssetClientProtocolPayload(ssh), false);
191+
assert.equal(isWebAssetClientProtocolPayload({ protocol: "http", asset: {} }), false);
192+
});
193+
144194
test("registers only jms2 and keeps the Windows development entry path as one argument", () => {
145195
const calls: unknown[][] = [];
146196
const app = {

‎i18n/locales/en.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1405,6 +1405,7 @@
14051405
"KubernetesDescription": "Kubernetes connections use the koko Kubernetes UI workspace.",
14061406
"RemoteDesktop": "Remote desktop",
14071407
"RemoteDesktopDescription": "RDP/VNC connections use the Lion remote desktop workspace.",
1408+
"ClientProxy": "Client",
14081409
"BuiltinWebProxy": "Built-in Browser",
14091410
"BuiltinWebProxyDescription": "Website assets use the system WebView, with traffic routed through Koko Web Proxy."
14101411
},

‎i18n/locales/es.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1405,6 +1405,7 @@
14051405
"KubernetesDescription": "Las conexiones K8s usan el workspace de Kubernetes UI de koko.",
14061406
"RemoteDesktop": "Escritorio remoto",
14071407
"RemoteDesktopDescription": "Las conexiones RDP/VNC usan el workspace de escritorio remoto de Lion.",
1408+
"ClientProxy": "Cliente",
14081409
"BuiltinWebProxy": "Navegador integrado",
14091410
"BuiltinWebProxyDescription": "Los activos de sitios web usan el WebView del sistema y el tráfico pasa por Koko Web Proxy."
14101411
},

‎i18n/locales/fr.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1405,6 +1405,7 @@
14051405
"KubernetesDescription": "Les connexions Kubernetes utilisent l’espace d’interface Kubernetes Koko.",
14061406
"RemoteDesktop": "Bureau distant",
14071407
"RemoteDesktopDescription": "Les connexions RDP/VNC utilisent l’espace de bureau distant Lion.",
1408+
"ClientProxy": "Client",
14081409
"BuiltinWebProxy": "Navigateur intégré",
14091410
"BuiltinWebProxyDescription": "Les actifs web utilisent la WebView du système, avec un trafic acheminé via Koko Web Proxy."
14101411
},

‎i18n/locales/ja.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1405,6 +1405,7 @@
14051405
"KubernetesDescription": "K8s 接続は koko Kubernetes UI workspace を使用します。",
14061406
"RemoteDesktop": "リモートデスクトップ",
14071407
"RemoteDesktopDescription": "RDP/VNC 接続は Lion リモートデスクトップ workspace を使用します。",
1408+
"ClientProxy": "クライアント",
14081409
"BuiltinWebProxy": "内蔵ブラウザ",
14091410
"BuiltinWebProxyDescription": "Website 資産はシステム WebView を使用し、トラフィックは Koko Web Proxy 経由で統一します。"
14101411
},

‎i18n/locales/ko.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1405,6 +1405,7 @@
14051405
"KubernetesDescription": "K8s 연결은 koko Kubernetes UI workspace를 사용합니다.",
14061406
"RemoteDesktop": "원격 데스크톱",
14071407
"RemoteDesktopDescription": "RDP/VNC 연결은 Lion 원격 데스크톱 workspace를 사용합니다.",
1408+
"ClientProxy": "클라이언트",
14081409
"BuiltinWebProxy": "내장 브라우저",
14091410
"BuiltinWebProxyDescription": "Website 자산은 시스템 WebView를 사용하며 트래픽은 Koko Web Proxy를 통해 전달됩니다."
14101411
},

‎i18n/locales/pt_br.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1405,6 +1405,7 @@
14051405
"KubernetesDescription": "Conexões Kubernetes usam o workspace da UI Kubernetes do koko.",
14061406
"RemoteDesktop": "Área de trabalho remota",
14071407
"RemoteDesktopDescription": "Conexões RDP/VNC usam o workspace de área de trabalho remota do Lion.",
1408+
"ClientProxy": "Cliente",
14081409
"BuiltinWebProxy": "Navegador integrado",
14091410
"BuiltinWebProxyDescription": "Ativos de website usam o WebView do sistema, com o tráfego roteado pelo Koko Web Proxy."
14101411
},

0 commit comments

Comments
 (0)