mirror of
https://github.com/abue-ammar/tinycast.git
synced 2026-10-02 00:04:44 +08:00
Add the WebSocket and mDNS support Home Assistant needs (#901)
* Run extensions that speak WebSocket A bundled `ws` handshakes through `http.request` and wants a raw socket back, which the fetch-backed shim had none of. Sockets are `URLSessionWebSocketTask` now, and the upgrade path hands `ws` one that re-frames RFC 6455 both ways. Home Assistant is the reference case. * Resolve a .local host without joining a multicast group Home Assistant's default `homeassistant.local` is resolved by the extension itself with multicast-dns, which died on `dgram.createSocket`. `dgram` now answers an address query out of `getaddrinfo` — mDNSResponder handles `.local` — so nothing multicasts and no entitlement is needed. * Harden the WebSocket and dgram shims A rejected host call poisoned the send queue, so every later send and the close after it rejected too; the queue now recovers while the caller still sees the failure. A ping is answered by the peer through `URLSessionWebSocketTask`, not by a pong the adapter invents. An upgrade no listener claims destroys the socket, the way Node does, so the native task goes with it. `dgram` answers address questions only. And a socket id that is not a whole number falls back instead of trapping.
This commit is contained in:
@@ -394,6 +394,87 @@ export default async function Command() {
|
||||
|
||||
// Hide My Email hands axios a cookie jar through axios-cookiejar-support, whose http-cookie-agent
|
||||
// extends `http.Agent` at load time and hooks each request in `addRequest` — the same way this does.
|
||||
// A bundled `ws` reaches the network the way this does: upgrade, then raw frames on the socket.
|
||||
// multicast-dns drives `dgram` the way this does, down to the packet it writes.
|
||||
const dgramSource = `
|
||||
import dgram from "node:dgram";
|
||||
|
||||
function query(name, type) {
|
||||
const labels = name.split(".");
|
||||
const packet = Buffer.alloc(12 + labels.reduce((total, label) => total + label.length + 1, 1) + 4);
|
||||
packet.writeUInt16BE(0x1234, 0);
|
||||
packet.writeUInt16BE(1, 4);
|
||||
let offset = 12;
|
||||
for (const label of labels) {
|
||||
packet[offset] = label.length;
|
||||
packet.write(label, offset + 1);
|
||||
offset += label.length + 1;
|
||||
}
|
||||
packet.writeUInt16BE(type, offset + 1);
|
||||
packet.writeUInt16BE(1, offset + 3);
|
||||
return packet;
|
||||
}
|
||||
|
||||
export default async function Command() {
|
||||
const socket = dgram.createSocket({ type: "udp4" });
|
||||
globalThis.__dgram = await new Promise((resolve) => {
|
||||
socket.on("message", (message, rinfo) => resolve({ hex: message.toString("hex"), port: rinfo.port }));
|
||||
socket.bind(5353, undefined, () => {
|
||||
const service = query("_services._dns-sd._udp.local", 12);
|
||||
socket.send(service, 0, service.length, 5353, "224.0.0.251");
|
||||
const packet = query("homeassistant.local", 1);
|
||||
socket.send(packet, 0, packet.length, 5353, "224.0.0.251");
|
||||
});
|
||||
});
|
||||
}
|
||||
`;
|
||||
|
||||
const websocketSource = `
|
||||
import https from "node:https";
|
||||
|
||||
export default async function Command() {
|
||||
globalThis.__ws = await new Promise((resolve, reject) => {
|
||||
const request = https.request({
|
||||
host: "example.test",
|
||||
path: "/socket",
|
||||
headers: {
|
||||
Connection: "Upgrade",
|
||||
Upgrade: "websocket",
|
||||
"Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
|
||||
"Sec-WebSocket-Version": "13",
|
||||
"Sec-WebSocket-Extensions": "permessage-deflate",
|
||||
},
|
||||
});
|
||||
request.on("error", reject);
|
||||
request.on("upgrade", (response, socket) => {
|
||||
const frames = [];
|
||||
socket.on("data", (chunk) => frames.push(chunk.toString("hex")));
|
||||
const payload = Buffer.from("ping", "utf8");
|
||||
const mask = Buffer.from([1, 2, 3, 4]);
|
||||
socket.write(
|
||||
Buffer.concat([
|
||||
Buffer.from([0x81, 0x80 | payload.length]),
|
||||
mask,
|
||||
Buffer.from(payload.map((byte, index) => byte ^ mask[index % 4])),
|
||||
]),
|
||||
);
|
||||
socket.write(Buffer.from([0x89, 0x80, 1, 2, 3, 4]));
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
status: response.statusCode,
|
||||
accept: response.headers["sec-websocket-accept"],
|
||||
extensions: response.headers["sec-websocket-extensions"] ?? null,
|
||||
frames,
|
||||
}),
|
||||
40,
|
||||
);
|
||||
});
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
`;
|
||||
|
||||
const cookieAgentSource = `
|
||||
import * as http from "node:http";
|
||||
import * as url from "node:url";
|
||||
@@ -887,6 +968,69 @@ export async function runFixtures() {
|
||||
},
|
||||
);
|
||||
|
||||
const socketOpens = [];
|
||||
const lookups = [];
|
||||
await run(
|
||||
"dgram answers an mDNS query out of the resolver",
|
||||
dgramSource,
|
||||
"no-view",
|
||||
async (harness) => {
|
||||
const result = harness.call("globalThis.__dgram");
|
||||
check("resolves the name the query asked for", lookups[0] === "homeassistant.local", String(lookups[0]));
|
||||
check("leaves a service question alone", lookups.length === 1, JSON.stringify(lookups));
|
||||
check("answers the query it was sent", result?.hex?.startsWith("123484000001000100000000"), String(result?.hex));
|
||||
check("names the host in the answer", result?.hex?.includes("0d686f6d65617373697374616e74056c6f63616c00"), String(result?.hex));
|
||||
check("carries the address as an A record", result?.hex?.endsWith("00010001000000780004c0a801e2"), String(result?.hex));
|
||||
},
|
||||
{
|
||||
stubs: {
|
||||
"dns.resolve": (args) => {
|
||||
lookups.push(args[0]);
|
||||
return ["192.168.1.226"];
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const socketSends = [];
|
||||
let socketReads = 0;
|
||||
let socketPings = 0;
|
||||
await run(
|
||||
"a websocket upgrade hands back a socket that frames both ways",
|
||||
websocketSource,
|
||||
"no-view",
|
||||
async (harness) => {
|
||||
const result = harness.call("globalThis.__ws");
|
||||
check("opens the native socket over wss", socketOpens[0]?.url === "wss://example.test/socket", JSON.stringify(socketOpens[0]?.url));
|
||||
check("drops the handshake headers", socketOpens[0]?.headers?.upgrade === undefined, JSON.stringify(socketOpens[0]?.headers));
|
||||
check("reports the upgrade", result?.status === 101, String(result?.status));
|
||||
check("answers the key the way a server would", result?.accept === "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", String(result?.accept));
|
||||
check("never accepts an extension", result?.extensions === null, String(result?.extensions));
|
||||
check("unmasks an outgoing frame", socketSends[0]?.text === "ping", JSON.stringify(socketSends[0]));
|
||||
check("frames an incoming message", result?.frames?.[0] === "8104706f6e67", JSON.stringify(result?.frames));
|
||||
check("asks the peer before answering a ping", socketPings === 1, String(socketPings));
|
||||
check("pongs once the peer answered", result?.frames?.includes("8a00"), JSON.stringify(result?.frames));
|
||||
},
|
||||
{
|
||||
stubs: {
|
||||
"websocket.open": (args) => {
|
||||
socketOpens.push(args[0]);
|
||||
return { id: 7, protocol: "" };
|
||||
},
|
||||
"websocket.send": (args) => {
|
||||
socketSends.push(args[0]);
|
||||
return null;
|
||||
},
|
||||
"websocket.ping": () => {
|
||||
socketPings++;
|
||||
return null;
|
||||
},
|
||||
// The second read never settles, which is what an idle socket looks like from JS.
|
||||
"websocket.receive": () => (socketReads++ === 0 ? { type: "text", text: "pong" } : new Promise(() => {})),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const cookieSpecs = [];
|
||||
const cookies = ["a=1; Expires=Wed, 21 Oct 2037 07:28:00 GMT; Path=/", "b=2; Path=/"];
|
||||
await run(
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// `dgram` exists for one case: multicast-dns resolving a `.local` host. The socket never reaches the
|
||||
// network — mDNSResponder already answers those, so the query goes to the system resolver instead.
|
||||
|
||||
import { Buffer } from "./buffer.js";
|
||||
import { EventEmitter } from "./events.js";
|
||||
import { hostCall } from "./host.js";
|
||||
|
||||
const MDNS_PORT = 5353;
|
||||
const A_RECORD = 1;
|
||||
const ANY_RECORD = 255;
|
||||
const IN_CLASS = 1;
|
||||
|
||||
class NameLookupSocket extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.port = MDNS_PORT;
|
||||
this.closed = false;
|
||||
}
|
||||
|
||||
bind(port, address, callback) {
|
||||
if (typeof port === "function") return this.bind(undefined, undefined, port);
|
||||
if (typeof address === "function") return this.bind(port, undefined, address);
|
||||
if (typeof port === "number") this.port = port;
|
||||
if (callback) this.once("listening", callback);
|
||||
setTimeout(() => this.emit("listening"), 0);
|
||||
return this;
|
||||
}
|
||||
|
||||
address() {
|
||||
return { address: "0.0.0.0", port: this.port, family: "IPv4" };
|
||||
}
|
||||
|
||||
send(buffer, offset = 0, length, port, address, callback) {
|
||||
if (port !== MDNS_PORT) {
|
||||
throw new Error("dgram only answers mDNS name lookups in Tinycast extensions. See docs/extensions.md.");
|
||||
}
|
||||
const packet = Buffer.from(buffer);
|
||||
this.answer(packet.subarray(offset, offset + (length ?? packet.length)));
|
||||
callback?.(null);
|
||||
return this;
|
||||
}
|
||||
|
||||
close(callback) {
|
||||
if (this.closed) return this;
|
||||
this.closed = true;
|
||||
if (callback) this.once("close", callback);
|
||||
setTimeout(() => this.emit("close"), 0);
|
||||
return this;
|
||||
}
|
||||
|
||||
// The resolver does the multicasting, so joining a group is nothing.
|
||||
addMembership() {}
|
||||
dropMembership() {}
|
||||
setMulticastTTL() {}
|
||||
setMulticastLoopback() {}
|
||||
setMulticastInterface() {}
|
||||
setTTL() {}
|
||||
ref() {
|
||||
return this;
|
||||
}
|
||||
unref() {
|
||||
return this;
|
||||
}
|
||||
|
||||
async answer(packet) {
|
||||
const query = decodeQuery(packet);
|
||||
if (!query) return;
|
||||
const answers = [];
|
||||
for (const question of query.questions) {
|
||||
if (question.class !== IN_CLASS) continue;
|
||||
if (question.type !== A_RECORD && question.type !== ANY_RECORD) continue;
|
||||
const addresses = await hostCall("dns", "resolve", [question.name]).catch(() => []);
|
||||
for (const address of addresses) answers.push({ name: question.name, address });
|
||||
}
|
||||
// Silence, the same as a name nothing on the network claims.
|
||||
if (this.closed || !answers.length) return;
|
||||
const response = encodeResponse(packet, query, answers);
|
||||
this.emit("message", response, {
|
||||
address: "127.0.0.1", family: "IPv4", port: this.port, size: response.length
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function decodeQuery(packet) {
|
||||
if (packet.length < 12) return null;
|
||||
const count = packet.readUInt16BE(4);
|
||||
const questions = [];
|
||||
let offset = 12;
|
||||
for (let index = 0; index < count; index++) {
|
||||
const labels = [];
|
||||
for (;;) {
|
||||
if (offset >= packet.length) return null;
|
||||
const size = packet[offset];
|
||||
// A query never compresses a name, so a pointer means this is not ours to answer.
|
||||
if (size >= 0xc0) return null;
|
||||
offset += 1;
|
||||
if (size === 0) break;
|
||||
labels.push(packet.subarray(offset, offset + size).toString("utf8"));
|
||||
offset += size;
|
||||
}
|
||||
if (offset + 4 > packet.length) return null;
|
||||
questions.push({
|
||||
name: labels.join("."),
|
||||
type: packet.readUInt16BE(offset),
|
||||
class: packet.readUInt16BE(offset + 2) & 0x7fff,
|
||||
});
|
||||
offset += 4;
|
||||
}
|
||||
return questions.length ? { id: packet.readUInt16BE(0), questions, end: offset } : null;
|
||||
}
|
||||
|
||||
function encodeResponse(packet, query, answers) {
|
||||
const header = Buffer.alloc(12);
|
||||
header.writeUInt16BE(query.id, 0);
|
||||
header.writeUInt16BE(0x8400, 2);
|
||||
header.writeUInt16BE(query.questions.length, 4);
|
||||
header.writeUInt16BE(answers.length, 6);
|
||||
return Buffer.concat([header, packet.subarray(12, query.end), ...answers.map(encodeRecord)]);
|
||||
}
|
||||
|
||||
function encodeRecord({ name, address }) {
|
||||
const record = Buffer.alloc(14);
|
||||
record.writeUInt16BE(A_RECORD, 0);
|
||||
record.writeUInt16BE(1, 2);
|
||||
record.writeUInt32BE(120, 4);
|
||||
record.writeUInt16BE(4, 8);
|
||||
address.split(".").forEach((part, index) => (record[10 + index] = Number(part)));
|
||||
return Buffer.concat([encodeName(name), record]);
|
||||
}
|
||||
|
||||
function encodeName(name) {
|
||||
const labels = name.split(".").filter(Boolean);
|
||||
const out = Buffer.alloc(labels.reduce((total, label) => total + label.length + 1, 1));
|
||||
let offset = 0;
|
||||
for (const label of labels) {
|
||||
out[offset] = label.length;
|
||||
out.write(label, offset + 1);
|
||||
offset += label.length + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const dgram = {
|
||||
createSocket(options, listener) {
|
||||
const socket = new NameLookupSocket();
|
||||
const handler = typeof options === "function" ? options : listener;
|
||||
if (handler) socket.on("message", handler);
|
||||
return socket;
|
||||
},
|
||||
Socket: NameLookupSocket,
|
||||
};
|
||||
@@ -15,6 +15,7 @@ import { NavigationRoot, setFieldCommandHandler } from "./api/components.js";
|
||||
import { Surface } from "./reconciler.js";
|
||||
import { raycastApi } from "./api/index.js";
|
||||
import { configureSystem, runToastAction } from "./api/system.js";
|
||||
import { WebSocket } from "./websocket.js";
|
||||
|
||||
const reactModule = {
|
||||
...React,
|
||||
@@ -41,6 +42,7 @@ defineModule("react-dom", {
|
||||
flushSync: (fn) => fn?.(),
|
||||
version: React.version,
|
||||
});
|
||||
globalThis.WebSocket = WebSocket;
|
||||
|
||||
const sessions = new Map();
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
import { ReadableStream, TransformStream, WritableStream } from "./web-streams.js";
|
||||
import { fileURLToPath, pathToFileURL, URL, URLSearchParams } from "./url.js";
|
||||
import { punycode } from "./punycode.js";
|
||||
import { upgradeToWebSocket } from "./websocket.js";
|
||||
import { dgram } from "./dgram.js";
|
||||
|
||||
// ─── path ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1242,6 +1244,7 @@ class ClientRequest extends EventEmitter {
|
||||
flushHeaders() {}
|
||||
|
||||
async _send() {
|
||||
if (String(this.getHeader("upgrade") ?? "").toLowerCase() === "websocket") return this._upgrade();
|
||||
// Content negotiation belongs to the transport, which decodes for us and reports the result.
|
||||
this.removeHeader("accept-encoding");
|
||||
const body = this._chunks.length ? Buffer.concat(this._chunks) : null;
|
||||
@@ -1265,21 +1268,64 @@ class ClientRequest extends EventEmitter {
|
||||
if (!this._destroyed) this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The host opens the socket, so the 101 is synthesised — never with an extension, so no deflate.
|
||||
async _upgrade() {
|
||||
const headers = this.getHeaders();
|
||||
try {
|
||||
const { socket, protocol } = await upgradeToWebSocket({
|
||||
url: this.url.replace(/^http/, "ws"),
|
||||
protocols: splitList(headers["sec-websocket-protocol"]),
|
||||
headers: Object.fromEntries(
|
||||
Object.entries(headers).filter(([name]) => !HANDSHAKE_HEADERS.has(name)),
|
||||
),
|
||||
});
|
||||
clearTimeout(this._timer);
|
||||
if (this._destroyed) return socket.destroy();
|
||||
const accept = new Hash("sha1").update(`${headers["sec-websocket-key"] ?? ""}${WEBSOCKET_GUID}`).digest("base64");
|
||||
const response = new IncomingMessage({
|
||||
status: 101,
|
||||
statusText: "Switching Protocols",
|
||||
headers: {
|
||||
upgrade: "websocket",
|
||||
connection: "Upgrade",
|
||||
"sec-websocket-accept": accept,
|
||||
...(protocol ? { "sec-websocket-protocol": protocol } : {}),
|
||||
},
|
||||
});
|
||||
if (!this.emit("upgrade", response, socket, Buffer.alloc(0))) socket.destroy();
|
||||
} catch (error) {
|
||||
clearTimeout(this._timer);
|
||||
if (!this._destroyed) this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function httpRequest(input, options, callback) {
|
||||
if (typeof options === "function") return httpRequest(input, {}, options);
|
||||
const WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||||
|
||||
/// URLSession writes the handshake itself; forwarding these would have it refuse the request.
|
||||
const HANDSHAKE_HEADERS = new Set([
|
||||
"connection", "upgrade", "host", "sec-websocket-key", "sec-websocket-version",
|
||||
"sec-websocket-extensions", "sec-websocket-protocol",
|
||||
]);
|
||||
|
||||
function splitList(value) {
|
||||
return String(value ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function httpRequest(input, options, callback, scheme = "http:") {
|
||||
if (typeof options === "function") return httpRequest(input, {}, options, scheme);
|
||||
if (typeof input === "string" || input instanceof URL) {
|
||||
return new ClientRequest(String(input), options ?? {}, callback);
|
||||
}
|
||||
const spec = input ?? {};
|
||||
const host = spec.hostname ?? spec.host ?? "localhost";
|
||||
const port = spec.port ? `:${spec.port}` : "";
|
||||
return new ClientRequest(`${spec.protocol ?? "http:"}//${host}${port}${spec.path ?? "/"}`, spec, callback);
|
||||
return new ClientRequest(`${spec.protocol ?? scheme}//${host}${port}${spec.path ?? "/"}`, spec, callback);
|
||||
}
|
||||
|
||||
function httpGet(input, options, callback) {
|
||||
return httpRequest(input, options, callback).end();
|
||||
function httpGet(input, options, callback, scheme) {
|
||||
return httpRequest(input, options, callback, scheme).end();
|
||||
}
|
||||
|
||||
// ─── util ───────────────────────────────────────────────────────────
|
||||
@@ -1534,8 +1580,9 @@ function makeUnsupported(label) {
|
||||
|
||||
const httpLike = (name) =>
|
||||
unsupportedModule(name, {
|
||||
request: httpRequest,
|
||||
get: httpGet,
|
||||
// The scheme rides with the module: `ws` and axios both pass an options bag with no protocol.
|
||||
request: (input, options, callback) => httpRequest(input, options, callback, `${name}:`),
|
||||
get: (input, options, callback) => httpGet(input, options, callback, `${name}:`),
|
||||
validateHeaderName,
|
||||
validateHeaderValue,
|
||||
IncomingMessage,
|
||||
@@ -1601,6 +1648,7 @@ export const nodeModules = {
|
||||
perf_hooks: { performance: globalThis.performance },
|
||||
http: httpLike("http"),
|
||||
https: httpLike("https"),
|
||||
dgram,
|
||||
net: unsupportedModule("net"),
|
||||
tls: unsupportedModule("tls"),
|
||||
dns: unsupportedModule("dns"),
|
||||
@@ -1624,10 +1672,10 @@ function requireStub(name) {
|
||||
}
|
||||
|
||||
// Every remaining Node builtin resolves to a refuse-on-use stub. Bundles reference the whole
|
||||
// long tail (dgram, http2, domain, repl, …) from dependencies that only touch them on paths an
|
||||
// long tail (http2, domain, repl, …) from dependencies that only touch them on paths an
|
||||
// extension never reaches, so a require-time throw would fail extensions that actually work.
|
||||
const REMAINING_BUILTINS = [
|
||||
"assert/strict", "console", "dgram", "diagnostics_channel", "dns/promises", "domain", "http2",
|
||||
"assert/strict", "console", "diagnostics_channel", "dns/promises", "domain", "http2",
|
||||
"inspector/promises", "path/posix", "path/win32", "readline/promises", "repl",
|
||||
"stream/consumers", "sys", "trace_events", "util/types", "wasi", "sea", "sqlite", "test",
|
||||
"test/reporters",
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
// WebSockets over `URLSessionWebSocketTask`: Swift owns the wire, JS reads by keeping one `receive`
|
||||
// outstanding.
|
||||
|
||||
import { Buffer } from "./buffer.js";
|
||||
import { hostCall } from "./host.js";
|
||||
import { Duplex } from "./streams.js";
|
||||
|
||||
/// Sends are chained: two host calls can otherwise settle out of order.
|
||||
class NativeSocket {
|
||||
constructor(handlers) {
|
||||
this.id = 0;
|
||||
this.protocol = "";
|
||||
this.closed = false;
|
||||
this.tail = Promise.resolve();
|
||||
this.handlers = handlers;
|
||||
}
|
||||
|
||||
async open(spec) {
|
||||
const opened = await hostCall("websocket", "open", [spec]);
|
||||
this.id = opened.id;
|
||||
this.protocol = opened.protocol ?? "";
|
||||
this.pump();
|
||||
return this;
|
||||
}
|
||||
|
||||
send(message) {
|
||||
return this.enqueue("send", { ...message });
|
||||
}
|
||||
|
||||
/// Outside the queue: a pong that never arrives must not hold a later `close` back.
|
||||
ping() {
|
||||
if (this.closed) return Promise.resolve();
|
||||
return hostCall("websocket", "ping", [{ id: this.id }]);
|
||||
}
|
||||
|
||||
close(code, reason) {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.enqueue("close", { code: code ?? 1000, reason: reason ?? "" }, { whenClosed: true });
|
||||
}
|
||||
|
||||
enqueue(method, payload, { whenClosed = false } = {}) {
|
||||
const result = this.tail.then(() =>
|
||||
this.closed && !whenClosed ? undefined : hostCall("websocket", method, [{ id: this.id, ...payload }]),
|
||||
);
|
||||
this.tail = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
async pump() {
|
||||
for (;;) {
|
||||
let event;
|
||||
try {
|
||||
event = await hostCall("websocket", "receive", [this.id]);
|
||||
} catch (error) {
|
||||
this.closed = true;
|
||||
this.handlers.closed(1006, String(error?.message ?? error), true);
|
||||
return;
|
||||
}
|
||||
if (event.type === "close") {
|
||||
this.closed = true;
|
||||
this.handlers.closed(event.code ?? 1006, event.reason ?? "", Boolean(event.abnormal));
|
||||
return;
|
||||
}
|
||||
const binary = event.type === "binary";
|
||||
this.handlers.message(
|
||||
binary ? Buffer.from(event.base64 ?? "", "base64") : String(event.text ?? ""),
|
||||
binary,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function protocolList(protocols) {
|
||||
if (!protocols) return [];
|
||||
return (Array.isArray(protocols) ? protocols : [protocols]).map(String);
|
||||
}
|
||||
|
||||
// ─── The WHATWG global ──────────────────────────────────────────────
|
||||
|
||||
export class WebSocket {
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
static CLOSING = 2;
|
||||
static CLOSED = 3;
|
||||
|
||||
constructor(url, protocols) {
|
||||
this.url = String(url);
|
||||
this.readyState = WebSocket.CONNECTING;
|
||||
this.protocol = "";
|
||||
this.extensions = "";
|
||||
this.binaryType = "arraybuffer";
|
||||
this.bufferedAmount = 0;
|
||||
this.onopen = null;
|
||||
this.onmessage = null;
|
||||
this.onerror = null;
|
||||
this.onclose = null;
|
||||
this._listeners = new Map();
|
||||
this._socket = null;
|
||||
new NativeSocket({
|
||||
message: (data, binary) => {
|
||||
this._fire("message", { data: binary && this.binaryType === "arraybuffer" ? toArrayBuffer(data) : data });
|
||||
},
|
||||
closed: (code, reason, abnormal) => this._end(code, reason, abnormal),
|
||||
})
|
||||
.open({ url: this.url, protocols: protocolList(protocols), headers: {} })
|
||||
.then(
|
||||
(socket) => {
|
||||
this._socket = socket;
|
||||
this.protocol = socket.protocol;
|
||||
// A `close()` during the handshake has to win over the open that lands after it.
|
||||
if (this.readyState !== WebSocket.CONNECTING) return socket.close(1000, "");
|
||||
this.readyState = WebSocket.OPEN;
|
||||
this._fire("open", {});
|
||||
},
|
||||
(error) => this._end(1006, String(error?.message ?? error), true),
|
||||
);
|
||||
}
|
||||
|
||||
send(data) {
|
||||
if (this.readyState !== WebSocket.OPEN) throw new Error("WebSocket is not open");
|
||||
if (typeof data === "string") return void this.transmit({ text: data });
|
||||
const bytes = ArrayBuffer.isView(data)
|
||||
? Buffer.from(data.buffer, data.byteOffset, data.byteLength)
|
||||
: Buffer.from(data);
|
||||
this.transmit({ base64: bytes.toString("base64") });
|
||||
}
|
||||
|
||||
transmit(message) {
|
||||
this._socket.send(message).catch((error) => this._end(1006, String(error?.message ?? error), true));
|
||||
}
|
||||
|
||||
close(code, reason) {
|
||||
if (this.readyState === WebSocket.CLOSED || this.readyState === WebSocket.CLOSING) return;
|
||||
const connecting = this.readyState === WebSocket.CONNECTING;
|
||||
this.readyState = WebSocket.CLOSING;
|
||||
this._socket?.close(code, reason);
|
||||
if (connecting && !this._socket) this._end(code ?? 1000, reason ?? "", false);
|
||||
}
|
||||
|
||||
addEventListener(type, listener) {
|
||||
if (!this._listeners.has(type)) this._listeners.set(type, new Set());
|
||||
this._listeners.get(type).add(listener);
|
||||
}
|
||||
|
||||
removeEventListener(type, listener) {
|
||||
this._listeners.get(type)?.delete(listener);
|
||||
}
|
||||
|
||||
_end(code, reason, abnormal) {
|
||||
if (this.readyState === WebSocket.CLOSED) return;
|
||||
if (abnormal) this._fire("error", { message: reason });
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
this._fire("close", { code, reason, wasClean: !abnormal });
|
||||
}
|
||||
|
||||
_fire(type, detail) {
|
||||
const event = { type, target: this, ...detail };
|
||||
const handler = this[`on${type}`];
|
||||
if (typeof handler === "function") handler.call(this, event);
|
||||
for (const listener of this._listeners.get(type) ?? []) listener.call(this, event);
|
||||
}
|
||||
}
|
||||
|
||||
function toArrayBuffer(bytes) {
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
}
|
||||
|
||||
// ─── The `ws` adapter ───────────────────────────────────────────────
|
||||
|
||||
const CONTINUATION = 0x0;
|
||||
const TEXT = 0x1;
|
||||
const BINARY = 0x2;
|
||||
const CLOSE = 0x8;
|
||||
const PING = 0x9;
|
||||
const PONG = 0xa;
|
||||
|
||||
/// A bundled `ws` frames its own traffic, so the socket it gets re-frames over the native task.
|
||||
export async function upgradeToWebSocket({ url, protocols, headers }) {
|
||||
const socket = new WebSocketSocket();
|
||||
const native = await new NativeSocket({
|
||||
message: (data, binary) => socket.deliver(data, binary),
|
||||
closed: (code, reason, abnormal) => socket.conclude(code, reason, abnormal),
|
||||
}).open({ url, protocols, headers });
|
||||
socket.native = native;
|
||||
return { socket, protocol: native.protocol };
|
||||
}
|
||||
|
||||
class WebSocketSocket extends Duplex {
|
||||
constructor() {
|
||||
super({ read() {} });
|
||||
this.native = null;
|
||||
this.fragments = [];
|
||||
this.fragmentOpcode = TEXT;
|
||||
this.pending = Buffer.alloc(0);
|
||||
}
|
||||
|
||||
_write(chunk, encoding, callback) {
|
||||
this.pending = Buffer.concat([this.pending, Buffer.from(chunk)]);
|
||||
while (this.readFrame());
|
||||
callback(null);
|
||||
}
|
||||
|
||||
/// `ws` destroys on every error path; the native task goes with it.
|
||||
_destroy(error, callback) {
|
||||
this.native?.close(1000, "");
|
||||
callback?.(error ?? null);
|
||||
}
|
||||
|
||||
setTimeout() {
|
||||
return this;
|
||||
}
|
||||
|
||||
setNoDelay() {
|
||||
return this;
|
||||
}
|
||||
|
||||
setKeepAlive() {
|
||||
return this;
|
||||
}
|
||||
|
||||
deliver(data, binary) {
|
||||
const payload = binary ? data : Buffer.from(data, "utf8");
|
||||
this.push(encodeFrame(binary ? BINARY : TEXT, payload));
|
||||
}
|
||||
|
||||
conclude(code, reason, abnormal) {
|
||||
// A frame may never carry 1005 or 1006; ending the socket is how `ws` reads them.
|
||||
if (!abnormal && code !== 1005 && code !== 1006) {
|
||||
const payload = Buffer.concat([Buffer.alloc(2), Buffer.from(String(reason ?? ""), "utf8")]);
|
||||
payload.writeUInt16BE(code, 0);
|
||||
this.push(encodeFrame(CLOSE, payload));
|
||||
}
|
||||
this.push(null);
|
||||
}
|
||||
|
||||
/// False when `pending` is short of a whole frame.
|
||||
readFrame() {
|
||||
const buffer = this.pending;
|
||||
if (buffer.length < 2) return false;
|
||||
const masked = (buffer[1] & 0x80) !== 0;
|
||||
const indicator = buffer[1] & 0x7f;
|
||||
let offset = 2;
|
||||
let length = indicator;
|
||||
if (indicator === 126) {
|
||||
if (buffer.length < 4) return false;
|
||||
length = buffer.readUInt16BE(2);
|
||||
offset = 4;
|
||||
} else if (indicator === 127) {
|
||||
if (buffer.length < 10) return false;
|
||||
length = buffer.readUInt32BE(2) * 2 ** 32 + buffer.readUInt32BE(6);
|
||||
offset = 10;
|
||||
}
|
||||
const mask = masked ? buffer.subarray(offset, offset + 4) : null;
|
||||
if (masked) offset += 4;
|
||||
if (buffer.length < offset + length) return false;
|
||||
const payload = Buffer.from(buffer.subarray(offset, offset + length));
|
||||
if (mask) for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i % 4];
|
||||
this.pending = Buffer.from(buffer.subarray(offset + length));
|
||||
this.handleFrame((buffer[0] & 0x80) !== 0, buffer[0] & 0x0f, payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
handleFrame(final, opcode, payload) {
|
||||
if (opcode === PONG) return;
|
||||
if (opcode === PING) {
|
||||
this.native?.ping().then(
|
||||
() => this.push(encodeFrame(PONG, payload)),
|
||||
(error) => this.destroy(error),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (opcode === CLOSE) {
|
||||
const code = payload.length >= 2 ? payload.readUInt16BE(0) : 1000;
|
||||
return void this.native?.close(code, payload.subarray(2).toString("utf8"));
|
||||
}
|
||||
if (!final) {
|
||||
if (opcode !== CONTINUATION) this.fragmentOpcode = opcode;
|
||||
this.fragments.push(payload);
|
||||
return;
|
||||
}
|
||||
if (opcode === CONTINUATION) {
|
||||
this.fragments.push(payload);
|
||||
const whole = Buffer.concat(this.fragments);
|
||||
this.fragments = [];
|
||||
return void this.transmit(this.fragmentOpcode, whole);
|
||||
}
|
||||
this.transmit(opcode, payload);
|
||||
}
|
||||
|
||||
transmit(opcode, payload) {
|
||||
const message = opcode === BINARY ? { base64: payload.toString("base64") } : { text: payload.toString("utf8") };
|
||||
this.native?.send(message)?.catch((error) => this.destroy(error));
|
||||
}
|
||||
}
|
||||
|
||||
function encodeFrame(opcode, payload) {
|
||||
const length = payload.length;
|
||||
const header = Buffer.alloc(length < 126 ? 2 : length < 65536 ? 4 : 10);
|
||||
header[0] = 0x80 | opcode;
|
||||
if (length < 126) {
|
||||
header[1] = length;
|
||||
} else if (length < 65536) {
|
||||
header[1] = 126;
|
||||
header.writeUInt16BE(length, 2);
|
||||
} else {
|
||||
header[1] = 127;
|
||||
header.writeUInt32BE(0, 2);
|
||||
header.writeUInt32BE(length, 6);
|
||||
}
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
randomUUID,
|
||||
} from "node:crypto";
|
||||
import { cpus, freemem, homedir, loadavg, tmpdir, uptime } from "node:os";
|
||||
import { lookup } from "node:dns/promises";
|
||||
import * as fs from "node:fs";
|
||||
import * as zlib from "node:zlib";
|
||||
|
||||
@@ -299,6 +300,8 @@ function syncHostCall(api, method, args) {
|
||||
|
||||
const oauthTokens = new Map();
|
||||
const runningChildren = new Map();
|
||||
const openSockets = new Map();
|
||||
let nextSocketId = 1;
|
||||
|
||||
async function stubHostCall(api, method, args) {
|
||||
switch (`${api}.${method}`) {
|
||||
@@ -335,6 +338,32 @@ async function stubHostCall(api, method, args) {
|
||||
runningChildren.delete(args[0]);
|
||||
return exit;
|
||||
}
|
||||
// Node's own WebSocket stands in for `URLSessionWebSocketTask`: same one-message-at-a-time read.
|
||||
case "websocket.open":
|
||||
return openSocket(args[0]);
|
||||
case "dns.resolve":
|
||||
return lookup(args[0], { all: true, family: 4 }).then(
|
||||
(found) => found.map((entry) => entry.address),
|
||||
() => [],
|
||||
);
|
||||
case "websocket.receive": {
|
||||
const entry = openSockets.get(args[0]);
|
||||
if (!entry) throw new Error("harness: no socket");
|
||||
return entry.queue.length ? entry.queue.shift() : new Promise((resolve) => entry.waiters.push(resolve));
|
||||
}
|
||||
case "websocket.send": {
|
||||
const entry = openSockets.get(args[0].id);
|
||||
entry?.socket.send(args[0].text ?? Buffer.from(args[0].base64, "base64"));
|
||||
return null;
|
||||
}
|
||||
case "websocket.close": {
|
||||
const entry = openSockets.get(args[0].id);
|
||||
openSockets.delete(args[0].id);
|
||||
entry?.socket.close(args[0].code, args[0].reason);
|
||||
return null;
|
||||
}
|
||||
case "websocket.ping":
|
||||
return null;
|
||||
// Positional arguments throughout, matching `src/api/oauth.js`.
|
||||
case "oauth.authorize":
|
||||
return { authorizationCode: "auth-code-12345", state: args[1] ?? "" };
|
||||
@@ -352,6 +381,30 @@ async function stubHostCall(api, method, args) {
|
||||
}
|
||||
}
|
||||
|
||||
async function openSocket(spec) {
|
||||
const socket = new WebSocket(spec.url, spec.protocols ?? []);
|
||||
socket.binaryType = "arraybuffer";
|
||||
const entry = { socket, queue: [], waiters: [] };
|
||||
const deliver = (event) => (entry.waiters.length ? entry.waiters.shift()(event) : entry.queue.push(event));
|
||||
socket.addEventListener("message", (event) =>
|
||||
deliver(
|
||||
typeof event.data === "string"
|
||||
? { type: "text", text: event.data }
|
||||
: { type: "binary", base64: Buffer.from(event.data).toString("base64") },
|
||||
),
|
||||
);
|
||||
socket.addEventListener("close", (event) =>
|
||||
deliver({ type: "close", code: event.code, reason: event.reason, abnormal: !event.wasClean }),
|
||||
);
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.addEventListener("open", resolve, { once: true });
|
||||
socket.addEventListener("error", () => reject(new Error(`connection to ${spec.url} failed`)), { once: true });
|
||||
});
|
||||
const id = nextSocketId++;
|
||||
openSockets.set(id, entry);
|
||||
return { id, protocol: socket.protocol ?? "" };
|
||||
}
|
||||
|
||||
export function bootConfig(overrides = {}) {
|
||||
return {
|
||||
node: {
|
||||
@@ -464,7 +517,7 @@ async function runExtension(dir, commandName) {
|
||||
);
|
||||
harness.start("s1", readFileSync(file, "utf8"), file, dir, target.mode === "view" ? "view" : "no-view", {});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
await new Promise((resolve) => setTimeout(resolve, Number(process.env.EXT_TEST_SETTLE_MS ?? 1500)));
|
||||
if (harness.state.failures.length) {
|
||||
console.log("\n✗ failures:");
|
||||
for (const failure of harness.state.failures) console.log(failure);
|
||||
|
||||
@@ -470,6 +470,8 @@ run slow ext-test -parse-as-library \
|
||||
$E/Service/ExtensionOAuthKeychain.swift \
|
||||
$E/Service/ExtensionOAuthSession.swift \
|
||||
$E/Service/ExtensionRuntime.swift \
|
||||
$E/Service/ExtensionNameResolver.swift \
|
||||
$E/Service/ExtensionWebSocketBridge.swift \
|
||||
$E/UI/ExtensionAnimatedImage.swift \
|
||||
$E/UI/ExtensionImage.swift \
|
||||
$E/UI/ExtensionScreen.swift \
|
||||
|
||||
@@ -26,6 +26,7 @@ struct ExtensionTests {
|
||||
var huds: [String] = []
|
||||
var oauthTokens: [String: String] = [:]
|
||||
private let fetcher = ExtensionFetcher()
|
||||
private let sockets = ExtensionWebSocketBridge()
|
||||
|
||||
func perform(api: String, method: String, arguments: [RenderValue]) async throws -> String {
|
||||
calls.append("\(api).\(method)")
|
||||
@@ -36,6 +37,13 @@ struct ExtensionTests {
|
||||
if api == "fetch" {
|
||||
return ExtensionRuntime.jsonString(from: try await fetcher.request(arguments.first))
|
||||
}
|
||||
if api == "websocket" {
|
||||
return ExtensionRuntime.jsonString(
|
||||
from: try await sockets.perform(method: method, arguments: arguments))
|
||||
}
|
||||
if api == "dns" {
|
||||
return ExtensionRuntime.jsonString(from: await ExtensionNameResolver.resolve(arguments.first))
|
||||
}
|
||||
switch "\(api).\(method)" {
|
||||
case "feedback.showToast":
|
||||
toasts.append(arguments.first?.objectValue?["title"]?.stringValue ?? "")
|
||||
@@ -71,6 +79,10 @@ struct ExtensionTests {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func sessionEnded() {
|
||||
sockets.closeAll()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
1BC52F49830E021342C037CA /* CalcTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBBB8484C670351136F3C535 /* CalcTimestamp.swift */; };
|
||||
1C81DCEBEFFC1191751135F4 /* PalettePlacement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 802BD2F4CC057228F2528217 /* PalettePlacement.swift */; };
|
||||
1CA10DE219BAB97BFC29E54A /* SettingsSidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D29ADBBE565419B1E98281 /* SettingsSidebarView.swift */; };
|
||||
1D087872616CA6AB68E2053B /* ExtensionWebSocketBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5787319A2BC8BA63CCE29F72 /* ExtensionWebSocketBridge.swift */; };
|
||||
1E04684F05DFA84A8B667F20 /* PasteboardFiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5C752F98428A767405AD12 /* PasteboardFiles.swift */; };
|
||||
1E0A1B1B84673EF39871842C /* MarkdownCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 570CE37C7133E28F7495ED91 /* MarkdownCodeView.swift */; };
|
||||
1E48F2BED1C62873F424F180 /* SnippetCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1847C927A1DA4EE0009201DF /* SnippetCoordinator.swift */; };
|
||||
@@ -623,6 +624,7 @@
|
||||
F71FC7195AC8D6626BEE8931 /* NotesPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91151402A1A4586B2496BF35 /* NotesPanel.swift */; };
|
||||
F801D00F877421DC7045C5F3 /* RedactedPlaceholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131B15F5EF86A067FA6C36D7 /* RedactedPlaceholder.swift */; };
|
||||
F85CE4CDCF7589ACAEF2CA20 /* AppIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90E23F92F4B4DEEE55077C7C /* AppIconView.swift */; };
|
||||
F88C1B7A8E2C46404792C826 /* ExtensionNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40D65C644389618DF5F5E26 /* ExtensionNameResolver.swift */; };
|
||||
F8D69CE2A8C04DEEC1810A1F /* UninstallRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D134673CB1B563A3758FC37 /* UninstallRunner.swift */; };
|
||||
F8E1B044D84E96FB455155CD /* AIModelDiscoveryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4B27ECEB1C09B14C403FC8 /* AIModelDiscoveryService.swift */; };
|
||||
F8EC987B884BD2ED6A6690A9 /* SnippetKeywordListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 301BA9123035D6BFB808F763 /* SnippetKeywordListener.swift */; };
|
||||
@@ -896,6 +898,7 @@
|
||||
570CE37C7133E28F7495ED91 /* MarkdownCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownCodeView.swift; sourceTree = "<group>"; };
|
||||
57640B0DA3F71E66CECA871C /* ExtensionFormKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionFormKey.swift; sourceTree = "<group>"; };
|
||||
57694B58B0A17CAC1D697FAA /* BundleNameCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleNameCache.swift; sourceTree = "<group>"; };
|
||||
5787319A2BC8BA63CCE29F72 /* ExtensionWebSocketBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionWebSocketBridge.swift; sourceTree = "<group>"; };
|
||||
582A70CF432F5E0144E57369 /* ExtensionRefreshState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionRefreshState.swift; sourceTree = "<group>"; };
|
||||
58D62BFB4EC604AE6171BBA3 /* CountryZoneData.generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountryZoneData.generated.swift; sourceTree = "<group>"; };
|
||||
590A432CC25ED6372B453BFA /* ScheduleScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleScreen.swift; sourceTree = "<group>"; };
|
||||
@@ -1175,6 +1178,7 @@
|
||||
C330E1D58D9A9A71D35D4019 /* QuicklinkEditorPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuicklinkEditorPanel.swift; sourceTree = "<group>"; };
|
||||
C3C83D437500107168E12647 /* QuickActionSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickActionSettings.swift; sourceTree = "<group>"; };
|
||||
C3E30EFA3197FC5EF1E85C21 /* BundleSignature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleSignature.swift; sourceTree = "<group>"; };
|
||||
C40D65C644389618DF5F5E26 /* ExtensionNameResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionNameResolver.swift; sourceTree = "<group>"; };
|
||||
C41B7D28CD2AF8AD14DA6FC3 /* SettingsAnchor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAnchor.swift; sourceTree = "<group>"; };
|
||||
C42ACB4D70CA089A6E948069 /* MenuSearchSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuSearchSession.swift; sourceTree = "<group>"; };
|
||||
C4515F099338A535A8736AE0 /* MenuSearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuSearchCoordinator.swift; sourceTree = "<group>"; };
|
||||
@@ -2813,12 +2817,14 @@
|
||||
B9F568F2F6EF857274F1A9D8 /* ExtensionIconCache.swift */,
|
||||
F023729C84682E9231F3D73B /* ExtensionInstaller.swift */,
|
||||
DD8CDDBAC59480E1A7559278 /* ExtensionManager.swift */,
|
||||
C40D65C644389618DF5F5E26 /* ExtensionNameResolver.swift */,
|
||||
9F9A0A3867D1B8E1D3284471 /* ExtensionNodeShims.swift */,
|
||||
CFE7A7AC9AEE81BAB9DA87FB /* ExtensionOAuthKeychain.swift */,
|
||||
DE7DEB147E5D718E00D67755 /* ExtensionOAuthSession.swift */,
|
||||
C54C4540AD6F188A2455473F /* ExtensionRuntime.swift */,
|
||||
46CECB81C669337C8E6CF7FF /* ExtensionStorage.swift */,
|
||||
410684C9DEB3754962C20C9C /* ExtensionStoreClient.swift */,
|
||||
5787319A2BC8BA63CCE29F72 /* ExtensionWebSocketBridge.swift */,
|
||||
387BE675F580496656E72559 /* SymbolCatalog.swift */,
|
||||
);
|
||||
path = Service;
|
||||
@@ -3446,6 +3452,7 @@
|
||||
2F2360730BE4992D5745FAD3 /* ExtensionListView.swift in Sources */,
|
||||
23E8792D69F3F1C539BC3BC6 /* ExtensionManager.swift in Sources */,
|
||||
45799EE1F41091B32544B6AE /* ExtensionManifest.swift in Sources */,
|
||||
F88C1B7A8E2C46404792C826 /* ExtensionNameResolver.swift in Sources */,
|
||||
552B7230E78D2C05793EC013 /* ExtensionNodeShims.swift in Sources */,
|
||||
8FDF7F20B5FBA1B4D4FF563D /* ExtensionOAuthKeychain.swift in Sources */,
|
||||
97F6E58046553BDCEF5AD447 /* ExtensionOAuthSession.swift in Sources */,
|
||||
@@ -3469,6 +3476,7 @@
|
||||
FC2F9731834BAD596116441A /* ExtensionStorePanel.swift in Sources */,
|
||||
0120AF7AB3AD249C484C8A03 /* ExtensionStoreResponse.swift in Sources */,
|
||||
62C75AA7315CDEFE82ECD21B /* ExtensionTintColors.swift in Sources */,
|
||||
1D087872616CA6AB68E2053B /* ExtensionWebSocketBridge.swift in Sources */,
|
||||
330540BBA991E36DD4C4BF53 /* ExtensionsSettingsView.swift in Sources */,
|
||||
0E2C10EDA6444D3ACF8F6F71 /* Fallback.swift in Sources */,
|
||||
DFED3F4C7B67FED3DBD016E5 /* FallbackActionsMenu.swift in Sources */,
|
||||
|
||||
@@ -128,6 +128,7 @@ final class ExtensionHostBridge: ExtensionHostAPI {
|
||||
weak var context: ExtensionHostContext?
|
||||
private let clipboardStore: ClipboardStore
|
||||
private let fetcher = ExtensionFetcher()
|
||||
private let sockets = ExtensionWebSocketBridge()
|
||||
|
||||
init(clipboardStore: ClipboardStore) {
|
||||
self.clipboardStore = clipboardStore
|
||||
@@ -147,6 +148,8 @@ final class ExtensionHostBridge: ExtensionHostAPI {
|
||||
case "feedback": return try await feedback(method: method, arguments: arguments)
|
||||
case "system": return try await system(method: method, arguments: arguments)
|
||||
case "fetch": return try await fetcher.request(arguments.first)
|
||||
case "websocket": return try await sockets.perform(method: method, arguments: arguments)
|
||||
case "dns": return await ExtensionNameResolver.resolve(arguments.first)
|
||||
case "proc": return try await ExtensionAsyncProcess.wait(arguments.first)
|
||||
case "oauth": return try await oauth(method: method, arguments: arguments)
|
||||
default: throw ExtensionHostError.unknown("\(api).\(method)")
|
||||
@@ -160,6 +163,11 @@ final class ExtensionHostBridge: ExtensionHostAPI {
|
||||
return (context, name)
|
||||
}
|
||||
|
||||
/// Called wherever a command's context is discarded: nothing left open outlives its session.
|
||||
func sessionEnded() {
|
||||
sockets.closeAll()
|
||||
}
|
||||
|
||||
// MARK: - Clipboard
|
||||
|
||||
private func clipboard(method: String, arguments: [RenderValue]) throws -> Any? {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
/// Host-name lookups for the `dgram` shim: the system resolver answers `.local` through Bonjour.
|
||||
enum ExtensionNameResolver {
|
||||
static func resolve(_ name: RenderValue?) async -> [String] {
|
||||
let host = name?.stringValue ?? ""
|
||||
guard !host.isEmpty else { return [] }
|
||||
return await Task.detached(priority: .userInitiated) { addresses(of: host) }.value
|
||||
}
|
||||
|
||||
/// Blocking, hence the detached task.
|
||||
private static func addresses(of host: String) -> [String] {
|
||||
var hints = addrinfo()
|
||||
hints.ai_family = AF_INET
|
||||
hints.ai_socktype = SOCK_STREAM
|
||||
var head: UnsafeMutablePointer<addrinfo>?
|
||||
guard getaddrinfo(host, nil, &hints, &head) == 0, let first = head else { return [] }
|
||||
defer { freeaddrinfo(first) }
|
||||
|
||||
var found: [String] = []
|
||||
var entry: UnsafeMutablePointer<addrinfo>? = first
|
||||
while let current = entry {
|
||||
var text = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
if getnameinfo(
|
||||
current.pointee.ai_addr, current.pointee.ai_addrlen, &text, socklen_t(text.count),
|
||||
nil, 0, NI_NUMERICHOST) == 0
|
||||
{
|
||||
let digits = text.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
|
||||
let address = String(decoding: digits, as: UTF8.self)
|
||||
if !found.contains(address) { found.append(address) }
|
||||
}
|
||||
entry = current.pointee.ai_next
|
||||
}
|
||||
return found
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import JavaScriptCore
|
||||
@MainActor
|
||||
protocol ExtensionHostAPI: AnyObject, Sendable {
|
||||
func perform(api: String, method: String, arguments: [RenderValue]) async throws -> String
|
||||
/// The context is gone; release anything opened on its behalf.
|
||||
func sessionEnded()
|
||||
}
|
||||
|
||||
/// Where a running command's UI or failure lands. Every callback arrives on the main actor.
|
||||
@@ -299,6 +301,8 @@ final class ExtensionRuntime: @unchecked Sendable {
|
||||
|
||||
/// Timers are global and React's scheduler rides them, so a context is never reused.
|
||||
func shutdown() {
|
||||
let hostAPI = self.hostAPI
|
||||
Task { @MainActor in hostAPI.sessionEnded() }
|
||||
queue.async {
|
||||
for timer in self.timers.values { timer.cancel() }
|
||||
self.timers.removeAll()
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import Foundation
|
||||
import Synchronization
|
||||
|
||||
/// One `URLSessionWebSocketTask` per socket, read by the single `receive` JS keeps in flight.
|
||||
final class ExtensionWebSocketBridge: NSObject, Sendable, URLSessionWebSocketDelegate {
|
||||
private struct Connection {
|
||||
let task: URLSessionWebSocketTask
|
||||
var opening: CheckedContinuation<String, Error>?
|
||||
}
|
||||
|
||||
private let connections = Mutex<[Int: Connection]>([:])
|
||||
private let sessionBox = Mutex<URLSession?>(nil)
|
||||
|
||||
enum SocketError: LocalizedError {
|
||||
case badURL(String)
|
||||
case closed
|
||||
case unknown(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .badURL(let url): return "Invalid WebSocket URL: \(url)"
|
||||
case .closed: return "The WebSocket is closed."
|
||||
case .unknown(let method): return "Unknown host call 'websocket.\(method)'."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func perform(method: String, arguments: [RenderValue]) async throws -> Any? {
|
||||
let fields = arguments.first?.objectValue ?? [:]
|
||||
switch method {
|
||||
case "open": return try await open(fields)
|
||||
case "receive": return try await receive(id: whole(arguments.first))
|
||||
case "send": return try await send(fields)
|
||||
case "ping": return try await ping(whole(fields["id"]))
|
||||
case "close":
|
||||
close(
|
||||
id: whole(fields["id"]), code: whole(fields["code"], or: 1000),
|
||||
reason: fields["reason"]?.stringValue ?? "")
|
||||
return nil
|
||||
default: throw SocketError.unknown(method)
|
||||
}
|
||||
}
|
||||
|
||||
private func whole(_ value: RenderValue?, or fallback: Int = 0) -> Int {
|
||||
value?.doubleValue.flatMap(Int.init(exactly:)) ?? fallback
|
||||
}
|
||||
|
||||
/// The context is thrown away between commands, so nothing would read these again.
|
||||
func closeAll() {
|
||||
let open = connections.withLock { state -> [Connection] in
|
||||
let all = Array(state.values)
|
||||
state.removeAll()
|
||||
return all
|
||||
}
|
||||
for connection in open {
|
||||
connection.opening?.resume(throwing: SocketError.closed)
|
||||
connection.task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Calls
|
||||
|
||||
private func open(_ fields: [String: RenderValue]) async throws -> [String: Any] {
|
||||
let text = fields["url"]?.stringValue ?? ""
|
||||
guard let url = URL(string: text), url.scheme == "ws" || url.scheme == "wss" else {
|
||||
throw SocketError.badURL(text)
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
for (name, value) in fields["headers"]?.objectValue ?? [:] {
|
||||
guard let header = value.stringValue else { continue }
|
||||
request.setValue(header, forHTTPHeaderField: name)
|
||||
}
|
||||
let protocols = (fields["protocols"]?.arrayValue ?? []).compactMap(\.stringValue)
|
||||
if !protocols.isEmpty {
|
||||
request.setValue(protocols.joined(separator: ", "), forHTTPHeaderField: "Sec-WebSocket-Protocol")
|
||||
}
|
||||
let task = session().webSocketTask(with: request)
|
||||
let id = task.taskIdentifier
|
||||
let negotiated = try await withCheckedThrowingContinuation { continuation in
|
||||
connections.withLock { $0[id] = Connection(task: task, opening: continuation) }
|
||||
task.resume()
|
||||
}
|
||||
return ["id": id, "protocol": negotiated]
|
||||
}
|
||||
|
||||
private func receive(id: Int) async throws -> [String: Any] {
|
||||
guard let task = connections.withLock({ $0[id]?.task }) else { throw SocketError.closed }
|
||||
do {
|
||||
switch try await task.receive() {
|
||||
case .string(let text): return ["type": "text", "text": text]
|
||||
case .data(let data): return ["type": "binary", "base64": data.base64EncodedString()]
|
||||
@unknown default: return ["type": "text", "text": ""]
|
||||
}
|
||||
} catch {
|
||||
connections.withLock { $0[id] = nil }
|
||||
let code = task.closeCode
|
||||
let clean = code != .invalid
|
||||
return [
|
||||
"type": "close",
|
||||
"code": clean ? code.rawValue : 1006,
|
||||
"reason": String(data: task.closeReason ?? Data(), encoding: .utf8) ?? "",
|
||||
"abnormal": !clean
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private func send(_ fields: [String: RenderValue]) async throws -> Any? {
|
||||
let id = whole(fields["id"])
|
||||
guard let task = connections.withLock({ $0[id]?.task }) else { throw SocketError.closed }
|
||||
if let base64 = fields["base64"]?.stringValue, let data = Data(base64Encoded: base64) {
|
||||
try await task.send(.data(data))
|
||||
} else {
|
||||
try await task.send(.string(fields["text"]?.stringValue ?? ""))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func ping(_ id: Int) async throws -> Any? {
|
||||
guard let task = connections.withLock({ $0[id]?.task }) else { throw SocketError.closed }
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
task.sendPing { error in
|
||||
if let error { continuation.resume(throwing: error) } else { continuation.resume() }
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func close(id: Int, code: Int, reason: String) {
|
||||
guard let connection = connections.withLock({ $0.removeValue(forKey: id) }) else { return }
|
||||
let closeCode = URLSessionWebSocketTask.CloseCode(rawValue: code) ?? .normalClosure
|
||||
connection.task.cancel(with: closeCode, reason: reason.data(using: .utf8))
|
||||
}
|
||||
|
||||
// MARK: - Session
|
||||
|
||||
/// Private and ephemeral, like every other networked surface: no cookie jar, no shared cache.
|
||||
private func session() -> URLSession {
|
||||
sessionBox.withLock { box in
|
||||
if let existing = box { return existing }
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.httpCookieStorage = nil
|
||||
configuration.urlCache = nil
|
||||
let created = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
|
||||
box = created
|
||||
return created
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URLSessionWebSocketDelegate
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession, webSocketTask: URLSessionWebSocketTask,
|
||||
didOpenWithProtocol protocolName: String?
|
||||
) {
|
||||
finishOpening(id: webSocketTask.taskIdentifier, result: .success(protocolName ?? ""))
|
||||
}
|
||||
|
||||
/// The only place a failed handshake surfaces: `receive` is never reached when one fails.
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
finishOpening(
|
||||
id: task.taskIdentifier,
|
||||
result: .failure(error ?? SocketError.closed))
|
||||
}
|
||||
|
||||
private func finishOpening(id: Int, result: Result<String, Error>) {
|
||||
let continuation = connections.withLock { state -> CheckedContinuation<String, Error>? in
|
||||
guard let opening = state[id]?.opening else { return nil }
|
||||
state[id]?.opening = nil
|
||||
if case .failure = result { state[id] = nil }
|
||||
return opening
|
||||
}
|
||||
continuation?.resume(with: result)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -93,6 +93,8 @@ same arrangement as `EmojiData.generated.swift`: building Tinycast never needs N
|
||||
| `src/api/oauth.js` | `OAuth.PKCEClient`, `OAuth.TokenSet`, redirect url builders |
|
||||
| `src/api/enums.generated.js` | Icon / Color / Toast.Style / … extracted from the real `@raycast/api` types |
|
||||
| `src/node-shims.js` | `path`, `fs`, `os`, `child_process`, `crypto`, `zlib`, `util`, `events`, `buffer`, `punycode`, … |
|
||||
| `src/websocket.js` | the `WebSocket` global, and the raw socket a bundled `ws` attaches to |
|
||||
| `src/dgram.js` | a UDP socket that answers one thing: an mDNS lookup of a `.local` name |
|
||||
| `src/url.js`, `src/punycode.js`, `src/buffer.js` | web/Node primitives JavaScriptCore lacks |
|
||||
|
||||
Two host-call flavours:
|
||||
@@ -114,6 +116,8 @@ Two host-call flavours:
|
||||
| `Service/ExtensionHostBridge.swift` | main-actor host APIs (clipboard, storage, cache, window, toasts, system, oauth) |
|
||||
| `Service/ExtensionNodeShims.swift` | the synchronous `fs` / `os` / `child_process` / `crypto` / `zlib` services |
|
||||
| `Service/ExtensionFetcher.swift` | `fetch` over `URLSession`, plus collecting async `exec` children and the shared PATH resolver |
|
||||
| `Service/ExtensionWebSocketBridge.swift` | `URLSessionWebSocketTask` connections, opened and read from JS |
|
||||
| `Service/ExtensionNameResolver.swift` | `getaddrinfo`, which is how a `.local` name resolves |
|
||||
| `Service/ExtensionOAuthKeychain.swift` | secure OAuth token storage backed by macOS Keychain |
|
||||
| `Service/ExtensionOAuthSession.swift` | PKCE state tracking, browser launch, and callback redirect resolution |
|
||||
| `Service/ExtensionStorage.swift` | per-extension `LocalStorage`, `Cache` and preference values (one JSON file each) |
|
||||
@@ -576,7 +580,7 @@ directions), `http`/`https` (`request`, `get` and `Agent`, buffered over the sam
|
||||
as `fetch`), `stream` (`Readable`, `Writable`, `Duplex`, `Transform`, `PassThrough`, `pipeline`,
|
||||
`finished`, plus `stream/promises` and `stream/web`), `util`, `events`, `buffer`, `url`, `querystring`, `punycode`, `assert`,
|
||||
`string_decoder`, `timers`. Every other built-in resolves to a stub that throws only when used, so a
|
||||
bundle that merely references `dgram` or `http2` still loads.
|
||||
bundle that merely references `http2` or `domain` still loads.
|
||||
|
||||
**Streams** — the stream core is Node's real contract, not a stand-in: an extension that ships
|
||||
`stream-chain` and `stream-json` to walk a package index builds object-mode pipelines out of it, and
|
||||
@@ -615,6 +619,26 @@ A request calls it only for an `http.Agent` subclass, which is where axios-cooki
|
||||
http-cookie-agent reads and writes its jar — Hide My Email is the reference case. URLSession folds
|
||||
repeated `Set-Cookie` headers into one line, so the response splits it back into Node's array.
|
||||
|
||||
**WebSockets** — `WebSocket` is a global backed by `URLSessionWebSocketTask`. Swift owns the wire and
|
||||
the framing, and JS reads a socket by keeping one `receive` call outstanding, so an inbound message
|
||||
needs no push channel; sends are chained, because two host calls can otherwise settle out of order.
|
||||
|
||||
A bundled `ws` never looks at that global. It runs its handshake through `http.request` and waits for
|
||||
an `upgrade` carrying a raw socket it frames itself, so the shim answers with one that re-frames RFC
|
||||
6455 in both directions on top of the native task. The 101 it synthesises names no extension, which
|
||||
is what keeps `permessage-deflate` — streaming zlib, which the shims have no answer for — off the
|
||||
connection. Home Assistant is the reference case: it authenticates, subscribes, and re-renders on
|
||||
every state push over that socket. The scheme rides with the module for the same reason: `ws` hands
|
||||
`https.request` an options bag with no protocol in it, and a `wss:` URL that went out as `ws:` would
|
||||
never connect.
|
||||
|
||||
**`.local` names** — Home Assistant's default URL is `homeassistant.local`, and the extension resolves
|
||||
it itself with `multicast-dns` because Node cannot. macOS can: mDNSResponder answers `.local` through
|
||||
`getaddrinfo` like any other name. So `dgram` hands out a socket that never reaches the network — it
|
||||
decodes the query, asks the system resolver, and emits an answer packet back. Nothing joins a
|
||||
multicast group, so no multicast entitlement and no Local Network prompt of our own. It answers an
|
||||
address question and nothing else: a service enumeration, or anything sent to another port, throws.
|
||||
|
||||
**Bundled helpers** — compiled Mach-O files and shebang scripts live in `assets/`. GitHub's raw-file
|
||||
downloads and some store zips lose their executable mode, so installation preserves Git tree mode
|
||||
`100755`; discovery also repairs known executable payloads already installed as `644`. That covers
|
||||
@@ -637,10 +661,10 @@ OAuth extensions it excluded are not counted yet — re-measure before quoting t
|
||||
| **`menu-bar` commands** | The launcher lists them and explains why they don't open. |
|
||||
| **Raycast's PKCE proxy (`oauth.raycast.com`)** | Extensions whose provider has no PKCE support exchange tokens through Raycast's proxy. `OAuth.PKCEClient` works; a provider that needs that proxy still fails. |
|
||||
| **`AI`, `BrowserExtension`, `WindowManagement`** | Raycast services with no local equivalent. Importing them works; calling one throws with a clear reason. |
|
||||
| **WebSocket** | No polyfill yet; `URLSessionWebSocketTask` could back one. |
|
||||
| **A WebSocket to a host with a certificate macOS distrusts** | `ws`'s `rejectUnauthorized: false` is ignored — URLSession validates the chain either way. |
|
||||
| **Aborting a `fetch` already in flight** | `AbortSignal` is complete — `timeout`, `abort` and `any` included — and `fetch` checks it on both sides of the host call, so a caller gets its `AbortError`. The request itself still runs to completion: the signal isn't carried across the bridge, so nothing cancels the `URLSessionTask`. A timeout bounds the caller, not the network. |
|
||||
| **Streaming `child_process.spawn`** | `spawn` runs the child to completion and emits its output as one chunk (async-iterable, which is what `get-stream`/`execa` consume). True duplex streaming would need a bidirectional channel across the bridge. Extensions built on `execa`'s deeper stream API can still fail. |
|
||||
| **`net` / `tls`** | Resolve but throw on use. Nothing bridges a socket. |
|
||||
| **`net` / `tls`** | Resolve but throw on use. Nothing bridges a raw socket; a bundled `ws` reaches the network through the WebSocket bridge instead. |
|
||||
| **Streaming HTTP** | The bridge answers a request with the whole body at once, so `http.request` delivers one chunk and `Response.body` replays bytes that already arrived. Server-sent events, network-level progress and backpressure onto the socket are all out of reach; `stream` itself is real enough to carry them the day the bridge is. |
|
||||
| **Tool/AI-extension entry points (`tools/`)** | Not surfaced. |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user