feat: enable subscription connections in web and dev
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { Model } from "@earendil-works/pi-ai";
|
||||
import { onWebSubscriptionPacket, webSubscriptionRequest, webSubscriptionsEnabled } from "./subscription-runtime.ts";
|
||||
import type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
|
||||
import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts";
|
||||
export type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
|
||||
@@ -20,14 +21,15 @@ const validStamp = (s: Stamp) => s && [s.epoch, s.authorityRevision, s.revision]
|
||||
function desktopIPCConnected(): boolean {
|
||||
return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true;
|
||||
}
|
||||
function runtimeConnected(): boolean { return desktopIPCConnected() || webSubscriptionsEnabled(); }
|
||||
function bridge(): BridgeState {
|
||||
const global = globalThis as typeof globalThis & { [key]?: BridgeState };
|
||||
if (!global[key]) {
|
||||
const state: BridgeState = { status: unavailable(), stamp: { epoch: 0, authorityRevision: 0, revision: 0 }, models: [], catalogAccount: null, pending: new Map(), controllers: new Map(), listeners: new Set(), counter: 0, catalogRequest: 0 };
|
||||
global[key] = state;
|
||||
process.on("message", (raw: unknown) => {
|
||||
const receive = (raw: unknown) => {
|
||||
const m = raw as { channel?: string; kind?: string; id?: string; op?: string; ok?: boolean; value?: unknown; stamp: Stamp; status: ChatGPTStatus; invalidate?: string; error?: string };
|
||||
if (!desktopIPCConnected() || m?.channel !== "lh:chatgpt") return;
|
||||
if (m?.channel !== "lh:chatgpt") return;
|
||||
if (m.kind === "state" && validStamp(m.stamp) && m.stamp.revision >= state.stamp.revision) {
|
||||
acceptStatus(state, { ...m.stamp, status: m.status });
|
||||
if (m.invalidate === "all") { state.catalogRequest++; clearCatalog(state); }
|
||||
@@ -43,8 +45,11 @@ function bridge(): BridgeState {
|
||||
if (m.ok === true) call.resolve(m.value);
|
||||
else call.reject(new Error(m.error ?? "ChatGPT connection failed."));
|
||||
}
|
||||
});
|
||||
};
|
||||
process.on("message", (raw: unknown) => { if (desktopIPCConnected()) receive(raw); });
|
||||
onWebSubscriptionPacket("lh:chatgpt", (packet) => { if (webSubscriptionsEnabled()) receive(packet); });
|
||||
process.on("disconnect", () => {
|
||||
if (webSubscriptionsEnabled()) return;
|
||||
state.status = unavailable(); clearCatalog(state); state.catalogRequest++;
|
||||
for (const controller of state.controllers.keys()) controller.abort("all");
|
||||
for (const call of state.pending.values()) { clearTimeout(call.timer); call.reject(new Error("The desktop connection closed.")); }
|
||||
@@ -60,9 +65,10 @@ function acceptStatus(state: BridgeState, value: StatusValue) {
|
||||
if (value.epoch !== state.stamp.epoch || value.authorityRevision !== state.stamp.authorityRevision || value.status.activeId !== state.status.activeId) clearCatalog(state);
|
||||
state.status = value.status; state.stamp = { epoch: value.epoch, authorityRevision: value.authorityRevision, revision: value.revision };
|
||||
}
|
||||
/** Runtime-only inherited IPC. No HTTP credential route or renderer method. */
|
||||
/** Runtime-only: inherited desktop IPC or direct web owner. Never HTTP leases. */
|
||||
export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
|
||||
const state = bridge();
|
||||
if (webSubscriptionsEnabled()) return webSubscriptionRequest<T>("lh:chatgpt", op, fields);
|
||||
if (!desktopIPCConnected()) return Promise.reject(new Error("ChatGPT sign-in is available only in the desktop app with a private server connection."));
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const id = `${process.pid}:${++state.counter}`;
|
||||
@@ -74,7 +80,7 @@ export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Rec
|
||||
});
|
||||
}
|
||||
export function chatGPTStatus(): ChatGPTStatus { return bridge().status; }
|
||||
export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return desktopIPCConnected() && s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); }
|
||||
export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return runtimeConnected() && s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); }
|
||||
export function chatGPTBackgroundAllowed(): boolean { const s = chatGPTStatus(); return chatGPTConnected() && s.allowBackgroundUse === true; }
|
||||
export function onChatGPTChange(listener: () => void): () => void { bridge().listeners.add(listener); return () => { bridge().listeners.delete(listener); }; }
|
||||
export function chatGPTModels(): Model<"openai-responses">[] { return bridge().models; }
|
||||
@@ -86,7 +92,7 @@ export function chatGPTScope(selectedModelId: string): AdmissionScope {
|
||||
}
|
||||
export function chatGPTScopeCurrent(scope: AdmissionScope): boolean {
|
||||
const s = bridge();
|
||||
return desktopIPCConnected() && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
|
||||
return runtimeConnected() && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
|
||||
}
|
||||
export async function chatGPTLease(scope: AdmissionScope, background: boolean): Promise<LeaseValue> {
|
||||
if (!chatGPTScopeCurrent(scope)) throw new Error("ChatGPT catalog authority changed");
|
||||
@@ -108,7 +114,7 @@ export async function chatGPTDenial(scope: AdmissionScope | undefined, controlle
|
||||
/** Preserve account order; unknown capabilities/windows are never fabricated. */
|
||||
export async function refreshChatGPTModels(supported: Model<"openai-responses">[]): Promise<void> {
|
||||
const state = bridge();
|
||||
if (!desktopIPCConnected()) { clearCatalog(state); return; }
|
||||
if (!runtimeConnected()) { clearCatalog(state); return; }
|
||||
const sequence = ++state.catalogRequest;
|
||||
try {
|
||||
const value = await chatGPTRequest<ModelsValue>("models");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Api, Model, ModelAuth } from "@earendil-works/pi-ai";
|
||||
import { onWebSubscriptionPacket, webSubscriptionRequest, webSubscriptionsEnabled } from "./subscription-runtime.ts";
|
||||
import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionProviderId, type NativeSubscriptionScope, type NativeSubscriptionSnapshot, type NativeSubscriptionStatus } from "../core/contracts/subscriptions.ts";
|
||||
import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts";
|
||||
|
||||
@@ -18,6 +19,7 @@ export function nativeSubscriptionId(logicalId: string): NativeSubscriptionProvi
|
||||
function desktopConnected(): boolean {
|
||||
return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true;
|
||||
}
|
||||
function runtimeConnected(): boolean { return desktopConnected() || webSubscriptionsEnabled(); }
|
||||
function notify(state: State) { for (const listener of state.listeners) { try { listener(); } catch { /* observer isolation */ } } }
|
||||
function invalidate(state: State, providerId?: NativeSubscriptionProviderId) {
|
||||
for (const [controller, owner] of state.controllers) if (!providerId || owner.providerId === providerId) controller.abort("all");
|
||||
@@ -60,7 +62,11 @@ function bridge(): State {
|
||||
if (message.ok) pending.resolve(message.value);
|
||||
else pending.reject(new Error(message.error ?? "Subscription connection failed."));
|
||||
});
|
||||
onWebSubscriptionPacket("lh:subscriptions", (packet) => {
|
||||
if (webSubscriptionsEnabled() && packet.snapshot) accept(state, packet.snapshot as NativeSubscriptionSnapshot);
|
||||
});
|
||||
process.on("disconnect", () => {
|
||||
if (webSubscriptionsEnabled()) return;
|
||||
invalidate(state);
|
||||
for (const pending of state.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error("The desktop connection closed.")); }
|
||||
state.pending.clear(); notify(state);
|
||||
@@ -70,6 +76,7 @@ function bridge(): State {
|
||||
}
|
||||
export function subscriptionRequest<T>(op: "snapshot" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
|
||||
const state = bridge();
|
||||
if (webSubscriptionsEnabled()) return webSubscriptionRequest<T>("lh:subscriptions", op, fields);
|
||||
if (!desktopConnected()) return Promise.reject(new Error("Subscription sign-in is available only in the desktop app with a private server connection."));
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const id = `${process.pid}:${++state.counter}`;
|
||||
@@ -82,17 +89,17 @@ export function subscriptionRequest<T>(op: "snapshot" | "lease", fields: Record<
|
||||
}
|
||||
export async function refreshSubscriptionModels(): Promise<void> {
|
||||
const state = bridge();
|
||||
if (!desktopConnected()) { invalidate(state); return; }
|
||||
if (!runtimeConnected()) { invalidate(state); return; }
|
||||
try { accept(state, await subscriptionRequest<NativeSubscriptionSnapshot>("snapshot")); }
|
||||
catch { invalidate(state); notify(state); }
|
||||
}
|
||||
export function nativeSubscriptionStatus(providerId: NativeSubscriptionProviderId): NativeSubscriptionStatus | undefined { return bridge().statuses.get(providerId); }
|
||||
export function nativeSubscriptionConnected(providerId: NativeSubscriptionProviderId): boolean {
|
||||
const status = nativeSubscriptionStatus(providerId);
|
||||
return desktopConnected() && status?.available === true && status.connected && !!status.connectionId;
|
||||
return runtimeConnected() && status?.available === true && status.connected && !!status.connectionId;
|
||||
}
|
||||
export function nativeSubscriptionBackgroundAllowed(providerId: NativeSubscriptionProviderId): boolean { return nativeSubscriptionConnected(providerId) && nativeSubscriptionStatus(providerId)?.allowBackgroundUse === true; }
|
||||
export function nativeSubscriptionModels(): Model<Api>[] { return desktopConnected() ? [...bridge().models.values()].flat() : []; }
|
||||
export function nativeSubscriptionModels(): Model<Api>[] { return runtimeConnected() ? [...bridge().models.values()].flat() : []; }
|
||||
export function nativeSubscriptionModel(logicalId: string, modelId: string): Model<Api> | undefined { return nativeSubscriptionModels().find((m) => m.provider === logicalId && m.id === modelId); }
|
||||
export function onNativeSubscriptionChange(listener: () => void): () => void { const state = bridge(); state.listeners.add(listener); return () => { state.listeners.delete(listener); }; }
|
||||
export function subscriptionScope(providerId: NativeSubscriptionProviderId, selectedModelId: string): NativeSubscriptionScope {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Server-injected private web transport. The agent layer never imports a
|
||||
* filesystem owner or server composition root. Desktop IPC remains separate. */
|
||||
export type SubscriptionRuntimeChannel = "lh:chatgpt" | "lh:subscriptions";
|
||||
export type SubscriptionRuntimePacket = Record<string, unknown>;
|
||||
interface Runtime {
|
||||
request?: <T>(channel: SubscriptionRuntimeChannel, op: string, fields: Record<string, unknown>) => Promise<T>;
|
||||
listeners: Map<SubscriptionRuntimeChannel, Set<(packet: SubscriptionRuntimePacket) => void>>;
|
||||
}
|
||||
const KEY = Symbol.for("lh.webSubscriptionRuntime");
|
||||
function runtime(): Runtime {
|
||||
const global = globalThis as typeof globalThis & { [KEY]?: Runtime };
|
||||
return global[KEY] ??= { listeners: new Map() };
|
||||
}
|
||||
export function configureWebSubscriptionRuntime(request: NonNullable<Runtime["request"]>): void { runtime().request = request; }
|
||||
export function webSubscriptionsEnabled(): boolean {
|
||||
return process.env.LH_DESKTOP_SERVER !== "1" && !process.env.LH_DESKTOP_API_SECRET && !!runtime().request;
|
||||
}
|
||||
export function onWebSubscriptionPacket(channel: SubscriptionRuntimeChannel, listener: (packet: SubscriptionRuntimePacket) => void): void {
|
||||
const state = runtime(); const listeners = state.listeners.get(channel) ?? new Set();
|
||||
listeners.add(listener); state.listeners.set(channel, listeners);
|
||||
}
|
||||
export function publishWebSubscriptionPacket(channel: SubscriptionRuntimeChannel, packet: SubscriptionRuntimePacket): void {
|
||||
for (const listener of runtime().listeners.get(channel) ?? []) {
|
||||
try { listener(packet); } catch { /* observer isolation */ }
|
||||
}
|
||||
}
|
||||
export function webSubscriptionRequest<T>(channel: SubscriptionRuntimeChannel, op: string, fields: Record<string, unknown>): Promise<T> {
|
||||
if (!webSubscriptionsEnabled()) return Promise.reject(new Error("The web subscription owner is unavailable."));
|
||||
return runtime().request!<T>(channel, op, fields);
|
||||
}
|
||||
@@ -38,6 +38,8 @@ export interface ServerEvents {
|
||||
"activity-changed": Record<string, never>;
|
||||
/** The session list changed (create, delete, title). */
|
||||
"chats-changed": Record<string, never>;
|
||||
/** Web subscription state changed. No account or credential data on the bus. */
|
||||
"subscriptions-changed": Record<string, never>;
|
||||
/** A chat's record changed WITHOUT a turn (the silent dismissal write).
|
||||
* Same notify-then-fetch contract as the turn events: open views of
|
||||
* the chat refetch and adopt the durable record. */
|
||||
@@ -61,6 +63,7 @@ export const SERVER_EVENT_TYPES = [
|
||||
"jobs-changed",
|
||||
"activity-changed",
|
||||
"chats-changed",
|
||||
"subscriptions-changed",
|
||||
"record-changed",
|
||||
"compaction-changed",
|
||||
] as const satisfies readonly ServerEventType[];
|
||||
|
||||
@@ -43,4 +43,5 @@ export * from "./compaction.ts";
|
||||
export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from "./pi/ui-stream.ts";
|
||||
export * from "./observability.ts";
|
||||
export * from "./model-catalog.ts";
|
||||
export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts";
|
||||
export { sessionCost } from "./pricing.ts";
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts";
|
||||
import { webSubscriptionOwner, webSubscriptionsEnabled, webSubscriptionStatus } from "./web-subscriptions.ts";
|
||||
|
||||
const HEADERS = { "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff" };
|
||||
/** Same-origin browser controls, not user authentication. Web/dev deliberately
|
||||
* retains the existing shared-server/API-key trust model. Never trust forwarded
|
||||
* origins or add CORS grants here. A public deployment needs its own access gate. */
|
||||
export function subscriptionRequestOrigin(request: Request): string | undefined {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const host = request.headers.get("host") ?? url.host;
|
||||
const addressed = new URL(`${url.protocol}//${host}`);
|
||||
if (!["http:", "https:"].includes(addressed.protocol) || addressed.username || addressed.password || addressed.pathname !== "/" || addressed.search || addressed.hash) return;
|
||||
return addressed.origin;
|
||||
} catch { return; }
|
||||
}
|
||||
function authorized(request: Request): boolean {
|
||||
const origin = subscriptionRequestOrigin(request);
|
||||
if (!origin || request.headers.get("x-learnharness-subscriptions") !== "1") return false;
|
||||
if (request.headers.get("sec-fetch-site") === "cross-site") return false;
|
||||
const supplied = request.headers.get("origin");
|
||||
return request.method === "GET" ? !supplied || supplied === origin : supplied === origin;
|
||||
}
|
||||
function error(message: string, status: number) { return Response.json({ error: message }, { status, headers: HEADERS }); }
|
||||
export async function subscriptionControlsResponse(request: Request): Promise<Response> {
|
||||
if (!webSubscriptionsEnabled()) return error("Web subscription controls are unavailable in desktop-owned mode.", 404);
|
||||
if (!authorized(request)) return error("Subscription controls require a same-origin LearnHarness request.", 403);
|
||||
try {
|
||||
if (request.method === "GET") return Response.json(await webSubscriptionStatus(), { headers: HEADERS });
|
||||
if (request.method !== "POST") return error("Unsupported method.", 405);
|
||||
if (request.headers.get("content-type")?.split(";")[0]?.trim() !== "application/json") return error("Expected JSON.", 415);
|
||||
// Bound both advertised and streamed input before parsing commands.
|
||||
if (Number(request.headers.get("content-length")) > 16384) return error("Request too large.", 413);
|
||||
const reader = request.body?.getReader();
|
||||
if (!reader) return error("Expected a command.", 400);
|
||||
const chunks: Uint8Array[] = []; let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const next = await reader.read(); if (next.done) break;
|
||||
size += next.value.byteLength;
|
||||
if (size > 16384) { await reader.cancel(); return error("Request too large.", 413); }
|
||||
chunks.push(next.value);
|
||||
}
|
||||
} finally { reader.releaseLock(); }
|
||||
const command: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
if (!command || typeof command !== "object" || Array.isArray(command)) return error("Invalid command.", 400);
|
||||
const fields = command as Record<string, unknown>;
|
||||
const providerId = fields.providerId;
|
||||
if (providerId !== "chatgpt" && !NATIVE_SUBSCRIPTIONS.some((provider) => provider.providerId === providerId)) return error("Unsupported connection.", 400);
|
||||
const allowed = providerId === "chatgpt" ? ["login", "cancel", "select", "disconnect", "logout", "consent"] : ["login", "cancel", "respond", "open", "retry", "disconnect", "consent"];
|
||||
if (typeof fields.op !== "string" || !allowed.includes(fields.op)) return error("Unsupported control.", 400);
|
||||
return Response.json(await webSubscriptionOwner().control(providerId as string, fields, subscriptionRequestOrigin(request)!), { headers: HEADERS });
|
||||
} catch {
|
||||
return error("The subscription connection could not be changed. Retry or reconnect in Providers.", 400);
|
||||
}
|
||||
}
|
||||
/** One-use same-origin navigation. Full provider authorization URLs go only to
|
||||
* a top-level browser redirect, never status JSON, the SSE bus or application JS. */
|
||||
export async function subscriptionBrowserResponse(request: Request): Promise<Response> {
|
||||
if (!webSubscriptionsEnabled()) return error("Unavailable.", 404);
|
||||
const origin = subscriptionRequestOrigin(request);
|
||||
if (!origin || (request.method === "POST" ? !authorized(request) : request.headers.get("sec-fetch-site") !== "same-origin")) return error("Open sign-in from LearnHarness.", 403);
|
||||
const id = new URL(request.url).searchParams.get("id");
|
||||
if (!id || !/^[0-9a-f-]{36}$/.test(id)) return error("This sign-in action is no longer available.", 410);
|
||||
const target = await webSubscriptionOwner().takeBrowserAction(id, origin);
|
||||
if (!target) return error("This sign-in action is no longer available.", 410);
|
||||
return new Response(null, { status: 303, headers: { ...HEADERS, Location: target } });
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/** Persistent Node web/dev owner. Never imported by client code. Desktop keeps
|
||||
* its existing main-process owner; a broken desktop IPC channel cannot fall
|
||||
* back to these files. One server process must own each configured auth dir. */
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { AUTH_FILE } from "../../config.ts";
|
||||
import { createChatGPT, fileStore } from "../../electron/chatgpt.cjs";
|
||||
import { createSubscriptions } from "../../electron/subscriptions.cjs";
|
||||
import { chatGPTControl } from "../../electron/chatgpt-ipc.cjs";
|
||||
import { subscriptionControl } from "../../electron/subscription-ipc.cjs";
|
||||
import { emit } from "../core/events.ts";
|
||||
import type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
|
||||
import type { NativeSubscriptionSnapshot } from "../core/contracts/subscriptions.ts";
|
||||
import { configureWebSubscriptionRuntime, publishWebSubscriptionPacket, webSubscriptionsEnabled, type SubscriptionRuntimeChannel as Channel, type SubscriptionRuntimePacket as Packet } from "../agent/subscription-runtime.ts";
|
||||
export { webSubscriptionsEnabled } from "../agent/subscription-runtime.ts";
|
||||
interface BrowserAction { target: string; origin: string; scope: string; expires: number; current(): Promise<boolean> }
|
||||
const KEY = Symbol.for("lh.webSubscriptions");
|
||||
interface Registry {
|
||||
owner?: ReturnType<typeof createWebSubscriptionOwner>;
|
||||
}
|
||||
function registry(): Registry {
|
||||
const global = globalThis as typeof globalThis & { [KEY]?: Registry };
|
||||
return global[KEY] ??= {};
|
||||
}
|
||||
function publish(channel: Channel, packet: Packet) {
|
||||
publishWebSubscriptionPacket(channel, packet);
|
||||
emit("subscriptions-changed", {});
|
||||
}
|
||||
async function loadSDK() {
|
||||
const [sdk, copilot, xai] = await Promise.all([
|
||||
import("@earendil-works/pi-ai"),
|
||||
import("@earendil-works/pi-ai/providers/github-copilot"),
|
||||
import("@earendil-works/pi-ai/providers/xai"),
|
||||
]);
|
||||
return { ...sdk, ...copilot, ...xai };
|
||||
}
|
||||
|
||||
/** Injectable factories/stores keep all test authentication offline. */
|
||||
export function createWebSubscriptionOwner(options: {
|
||||
directory: string;
|
||||
chatgptStore?: Parameters<typeof createChatGPT>[0]["store"];
|
||||
sdk?: unknown;
|
||||
onPacket?: (channel: Channel, packet: Packet) => void;
|
||||
} ) {
|
||||
const notify = options.onPacket ?? (() => {});
|
||||
const launches = new AsyncLocalStorage<string[]>();
|
||||
const actions = new Map<string, BrowserAction>();
|
||||
let chatGPTLaunch = 0;
|
||||
const openExternal = async (target: string) => {
|
||||
const captured = launches.getStore();
|
||||
if (!captured || new URL(target).protocol !== "https:") throw new Error("No browser action is pending.");
|
||||
captured.push(target);
|
||||
};
|
||||
const chatgpt = createChatGPT({
|
||||
store: options.chatgptStore ?? fileStore(path.join(options.directory, "chatgpt-credentials.json")),
|
||||
openExternal,
|
||||
onChange: (packet?: Packet) => { if (packet) notify("lh:chatgpt", packet); },
|
||||
});
|
||||
const native = createSubscriptions({
|
||||
directory: options.directory, sdk: options.sdk, loadSDK, openExternal,
|
||||
onChange: (snapshot: NativeSubscriptionSnapshot) => notify("lh:subscriptions", { channel: "lh:subscriptions", op: "changed", snapshot }),
|
||||
});
|
||||
return {
|
||||
chatgpt, native,
|
||||
async control(providerId: string, command: Record<string, unknown>, origin: string) {
|
||||
const targets: string[] = [];
|
||||
const value = await launches.run(targets, () => providerId === "chatgpt"
|
||||
? chatGPTControl(command, chatgpt)
|
||||
: subscriptionControl({ ...command, providerId }, native));
|
||||
let browserAction: string | undefined;
|
||||
const target = targets[0];
|
||||
if (target) {
|
||||
const scope = providerId === "chatgpt" ? "chatgpt" : `${providerId}:${command.attemptId}:${command.actionId}`;
|
||||
for (const [id, action] of actions) if (action.expires <= Date.now() || action.scope === scope) actions.delete(id);
|
||||
const id = randomUUID();
|
||||
const generation = providerId === "chatgpt" ? ++chatGPTLaunch : chatGPTLaunch;
|
||||
const current = providerId === "chatgpt"
|
||||
? async () => generation === chatGPTLaunch && (await chatgpt.status()).pending === true
|
||||
: async () => (await native.status()).some((status: { providerId: string; interaction?: { attemptId: string; browser?: { id: string } } }) => status.providerId === providerId && status.interaction?.attemptId === command.attemptId && status.interaction?.browser?.id === command.actionId);
|
||||
actions.set(id, { target, origin, scope, expires: Date.now() + 180000, current });
|
||||
browserAction = `/api/subscriptions/browser?id=${id}`;
|
||||
}
|
||||
return { value, ...(browserAction ? { browserAction } : {}) };
|
||||
},
|
||||
async takeBrowserAction(id: string, origin: string): Promise<string | undefined> {
|
||||
const action = actions.get(id);
|
||||
if (!action || action.origin !== origin) return;
|
||||
actions.delete(id);
|
||||
if (action.expires <= Date.now() || !await action.current()) return;
|
||||
return action.target;
|
||||
},
|
||||
close() { actions.clear(); chatgpt.close(); native.close(); },
|
||||
};
|
||||
}
|
||||
export function webSubscriptionOwner() {
|
||||
if (!webSubscriptionsEnabled()) throw new Error("Web subscription controls are unavailable in desktop-owned mode.");
|
||||
const state = registry();
|
||||
if (!state.owner) {
|
||||
state.owner = createWebSubscriptionOwner({ directory: path.dirname(AUTH_FILE), onPacket: publish });
|
||||
process.once("exit", () => state.owner?.close());
|
||||
}
|
||||
return state.owner;
|
||||
}
|
||||
/** Private runtime calls only: no HTTP lease/token operation exists. */
|
||||
export async function webSubscriptionRequest<T>(channel: Channel, op: string, fields: Record<string, unknown>): Promise<T> {
|
||||
const owner = webSubscriptionOwner();
|
||||
let value: unknown;
|
||||
if (channel === "lh:chatgpt") {
|
||||
if (op === "status") value = { status: await owner.chatgpt.status(), ...owner.chatgpt.stamp() };
|
||||
else if (op === "models") value = await owner.chatgpt.models();
|
||||
else if (op === "lease" && typeof fields.background === "boolean") value = await owner.chatgpt.lease(fields.background, fields);
|
||||
else throw new Error("Invalid private operation.");
|
||||
} else {
|
||||
if (op === "snapshot") value = await owner.native.snapshot();
|
||||
else if (op === "lease" && typeof fields.background === "boolean") value = await owner.native.lease(fields.scope, fields.background);
|
||||
else throw new Error("Invalid private operation.");
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
configureWebSubscriptionRuntime(webSubscriptionRequest);
|
||||
|
||||
export async function webSubscriptionStatus(): Promise<{ chatgpt: ChatGPTStatus; native: NativeSubscriptionSnapshot["statuses"] }> {
|
||||
const owner = webSubscriptionOwner();
|
||||
const [chatgpt, native] = await Promise.all([owner.chatgpt.status(), owner.native.status()]);
|
||||
return { chatgpt, native };
|
||||
}
|
||||
Reference in New Issue
Block a user