| import { parseNodeList, parsePairingList } from "../../shared/node-list-parse.js"; |
| import type { NodeListNode } from "../../shared/node-list-types.js"; |
| import { resolveNodeFromNodeList, resolveNodeIdFromNodeList } from "../../shared/node-resolve.js"; |
| import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; |
|
|
| export type { NodeListNode }; |
|
|
| type DefaultNodeFallback = "none" | "first"; |
|
|
| type DefaultNodeSelectionOptions = { |
| capability?: string; |
| fallback?: DefaultNodeFallback; |
| preferLocalMac?: boolean; |
| }; |
|
|
| function messageFromError(error: unknown): string { |
| if (error instanceof Error) { |
| return error.message; |
| } |
| if (typeof error === "string") { |
| return error; |
| } |
| if ( |
| typeof error === "object" && |
| error !== null && |
| "message" in error && |
| typeof (error as { message?: unknown }).message === "string" |
| ) { |
| return (error as { message: string }).message; |
| } |
| if (typeof error === "object" && error !== null) { |
| try { |
| return JSON.stringify(error); |
| } catch { |
| return ""; |
| } |
| } |
| return ""; |
| } |
|
|
| function shouldFallbackToPairList(error: unknown): boolean { |
| const message = messageFromError(error).toLowerCase(); |
| if (!message.includes("node.list")) { |
| return false; |
| } |
| return ( |
| message.includes("unknown method") || |
| message.includes("method not found") || |
| message.includes("not implemented") || |
| message.includes("unsupported") |
| ); |
| } |
|
|
| async function loadNodes(opts: GatewayCallOptions): Promise<NodeListNode[]> { |
| try { |
| const res = await callGatewayTool("node.list", opts, {}); |
| return parseNodeList(res); |
| } catch (error) { |
| if (!shouldFallbackToPairList(error)) { |
| throw error; |
| } |
| const res = await callGatewayTool("node.pair.list", opts, {}); |
| const { paired } = parsePairingList(res); |
| return paired.map((n) => ({ |
| nodeId: n.nodeId, |
| displayName: n.displayName, |
| platform: n.platform, |
| remoteIp: n.remoteIp, |
| })); |
| } |
| } |
|
|
| function isLocalMacNode(node: NodeListNode): boolean { |
| return ( |
| node.platform?.toLowerCase().startsWith("mac") === true && |
| typeof node.nodeId === "string" && |
| node.nodeId.startsWith("mac-") |
| ); |
| } |
|
|
| function compareDefaultNodeOrder(a: NodeListNode, b: NodeListNode): number { |
| const aConnectedAt = Number.isFinite(a.connectedAtMs) ? (a.connectedAtMs ?? 0) : -1; |
| const bConnectedAt = Number.isFinite(b.connectedAtMs) ? (b.connectedAtMs ?? 0) : -1; |
| if (aConnectedAt !== bConnectedAt) { |
| return bConnectedAt - aConnectedAt; |
| } |
| return a.nodeId.localeCompare(b.nodeId); |
| } |
|
|
| export function selectDefaultNodeFromList( |
| nodes: NodeListNode[], |
| options: DefaultNodeSelectionOptions = {}, |
| ): NodeListNode | null { |
| const capability = options.capability?.trim(); |
| const withCapability = capability |
| ? nodes.filter((n) => (Array.isArray(n.caps) ? n.caps.includes(capability) : true)) |
| : nodes; |
| if (withCapability.length === 0) { |
| return null; |
| } |
|
|
| const connected = withCapability.filter((n) => n.connected); |
| const candidates = connected.length > 0 ? connected : withCapability; |
| if (candidates.length === 1) { |
| return candidates[0]; |
| } |
|
|
| const preferLocalMac = options.preferLocalMac ?? true; |
| if (preferLocalMac) { |
| const local = candidates.filter(isLocalMacNode); |
| if (local.length === 1) { |
| return local[0]; |
| } |
| } |
|
|
| const fallback = options.fallback ?? "none"; |
| if (fallback === "none") { |
| return null; |
| } |
|
|
| const ordered = [...candidates].toSorted(compareDefaultNodeOrder); |
| |
| |
| |
| return ordered[0] ?? null; |
| } |
|
|
| function pickDefaultNode(nodes: NodeListNode[]): NodeListNode | null { |
| return selectDefaultNodeFromList(nodes, { |
| capability: "canvas", |
| fallback: "first", |
| preferLocalMac: true, |
| }); |
| } |
|
|
| export async function listNodes(opts: GatewayCallOptions): Promise<NodeListNode[]> { |
| return loadNodes(opts); |
| } |
|
|
| export function resolveNodeIdFromList( |
| nodes: NodeListNode[], |
| query?: string, |
| allowDefault = false, |
| ): string { |
| return resolveNodeIdFromNodeList(nodes, query, { |
| allowDefault, |
| pickDefaultNode: pickDefaultNode, |
| }); |
| } |
|
|
| export async function resolveNodeId( |
| opts: GatewayCallOptions, |
| query?: string, |
| allowDefault = false, |
| ) { |
| return (await resolveNode(opts, query, allowDefault)).nodeId; |
| } |
|
|
| export async function resolveNode( |
| opts: GatewayCallOptions, |
| query?: string, |
| allowDefault = false, |
| ): Promise<NodeListNode> { |
| const nodes = await loadNodes(opts); |
| return resolveNodeFromNodeList(nodes, query, { |
| allowDefault, |
| pickDefaultNode: pickDefaultNode, |
| }); |
| } |
|
|