mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-01 23:12:29 +00:00
c46b4a919a
* Add flight-map-canvas extension A canvas port of the Flight Map VSCode extension: a first-person satellite terrain map flown with flight simulator controls, for session breaks while an agent works. The simulator under game/ is copied verbatim from the source extension's media/ folder. That page already reached its host through one seam - an acquireVsCodeApi() object and a placeholder in its head - so extension.mjs fills that seam for the canvas: a loopback server that injects a policy, the render configuration, and a shim translating Server-Sent Events into the messages the page already handles. Two agent actions: fly_to sends the flight to a capital, a geocoded city, or a raw lat/lng, and picks a random capital when called with no input; report_job shows the current job step under the HUD. Adds the vendored three.min.js to the codespell skip list, matching the existing entry for arcade-canvas's phaser.min.js. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Apply suggestions from code review --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
633 lines
23 KiB
JavaScript
633 lines
23 KiB
JavaScript
/**
|
|
* Copilot canvas entry point for Flight Map.
|
|
*
|
|
* The simulator itself is the same page the VS Code extension ships: a
|
|
* plain Three.js document under game/ that loads satellite tiles, flies a
|
|
* first-person camera over them, and reads its render configuration off
|
|
* `window.__flightMapConfig`. This file is only the host. It runs a
|
|
* loopback HTTP server that serves that page, and registers a canvas so
|
|
* an agent can steer the flight and report what it is working on.
|
|
*
|
|
* The page carries a `<!--{{HOST_HEAD}}-->` placeholder that each host
|
|
* fills with whatever only it can supply. The VS Code panel puts a
|
|
* content security policy, webview asset URIs, and the render config
|
|
* there. This server puts a policy, the render config, and a shim that
|
|
* stands in for `acquireVsCodeApi()`, so the page runs unchanged in both.
|
|
*
|
|
* Messages the shim relays, matching the commands the page already
|
|
* registers through media/hostBridge.js:
|
|
*
|
|
* host -> page : { command: 'flyTo', destination }
|
|
* { command: 'jobStatus', job }
|
|
* page -> host : { command: 'ready' }
|
|
* { command: 'exportConfig', json } // handled in the shim
|
|
*/
|
|
import { createReadStream } from "node:fs";
|
|
import { readFile, stat } from "node:fs/promises";
|
|
import { createServer } from "node:http";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { randomBytes } from "node:crypto";
|
|
|
|
import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const gameRoot = path.join(__dirname, "game");
|
|
const assetsRoot = path.join(__dirname, "assets");
|
|
const indexPath = path.join(gameRoot, "index.html");
|
|
|
|
/** Idle agent job; the status strip hides itself. */
|
|
const IDLE_JOB = { working: false, status: "", tokens: 0 };
|
|
|
|
/**
|
|
* Nominatim asks every client to identify itself. The canvas makes at most
|
|
* one lookup per fly_to, well inside the one-request-per-second policy.
|
|
*/
|
|
const USER_AGENT = "flight-map-canvas (https://github.com/isocialPractice/vscode-flight-map)";
|
|
const NOMINATIM = "https://nominatim.openstreetmap.org/search";
|
|
|
|
/**
|
|
* The tile imagery the page flies over is Web Mercator, which runs out of
|
|
* projection at about 85.0511 degrees. Past that there are no tiles to
|
|
* fetch, so a latitude beyond it is rejected rather than flown to a blank.
|
|
*/
|
|
const MAX_LATITUDE = 85.0511;
|
|
|
|
const servers = new Map();
|
|
|
|
/**
|
|
* The capitals the page ships with, read out of its own data file rather
|
|
* than duplicated here. game/capitals.js is a plain script that assigns
|
|
* one `var`, so evaluating it and returning that binding keeps this
|
|
* server and the page reading from a single list.
|
|
*/
|
|
const CAPITALS = await (async () => {
|
|
const source = await readFile(path.join(gameRoot, "capitals.js"), "utf8");
|
|
const regions = new Function(`${source}\nreturn CAPITALS_BY_REGION;`)();
|
|
return regions.flatMap((region) =>
|
|
region.capitals.map((capital) => ({ ...capital, region: region.region })),
|
|
);
|
|
})();
|
|
|
|
/** The render configuration the page starts on, or defaults when absent. */
|
|
async function readRenderConfig() {
|
|
try {
|
|
return JSON.parse(await readFile(path.join(__dirname, "renderMap.json"), "utf8"));
|
|
} catch {
|
|
// Missing or malformed: the page falls back to its own defaults
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function contentType(filePath) {
|
|
switch (path.extname(filePath).toLowerCase()) {
|
|
case ".html":
|
|
return "text/html; charset=utf-8";
|
|
case ".js":
|
|
return "text/javascript; charset=utf-8";
|
|
case ".css":
|
|
return "text/css; charset=utf-8";
|
|
case ".json":
|
|
return "application/json; charset=utf-8";
|
|
case ".png":
|
|
return "image/png";
|
|
case ".jpg":
|
|
case ".jpeg":
|
|
return "image/jpeg";
|
|
case ".svg":
|
|
return "image/svg+xml";
|
|
case ".webp":
|
|
return "image/webp";
|
|
default:
|
|
return "application/octet-stream";
|
|
}
|
|
}
|
|
|
|
/** Resolves a request path under a root, refusing anything that escapes it. */
|
|
function resolveUnder(root, requestPath) {
|
|
const resolved = path.resolve(root, `.${requestPath}`);
|
|
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
|
|
throw new CanvasError("invalid_path", "Requested path is outside the flight map assets.");
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
function sendNotFound(res) {
|
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
res.end("Not found");
|
|
}
|
|
|
|
function sendSse(res, event, data) {
|
|
res.write(`event: ${event}\n`);
|
|
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
}
|
|
|
|
function broadcast(entry, event, data) {
|
|
for (const client of entry.clients) {
|
|
sendSse(client, event, data);
|
|
}
|
|
}
|
|
|
|
/* ============================================================
|
|
Destination resolution
|
|
============================================================ */
|
|
|
|
function normalizeText(value) {
|
|
return typeof value === "string" ? value.trim() : "";
|
|
}
|
|
|
|
function randomCapital() {
|
|
return CAPITALS[Math.floor(Math.random() * CAPITALS.length)];
|
|
}
|
|
|
|
function asDestination(capital) {
|
|
return {
|
|
capital: capital.capital,
|
|
country: capital.country,
|
|
lat: capital.lat,
|
|
lng: capital.lng,
|
|
label: `${capital.capital}, ${capital.country}`,
|
|
};
|
|
}
|
|
|
|
/** Matches a name against the shipped capitals, by city or by country. */
|
|
function findCapital(name) {
|
|
const needle = name.toLowerCase();
|
|
return (
|
|
CAPITALS.find((c) => c.capital.toLowerCase() === needle) ??
|
|
CAPITALS.find((c) => c.country.toLowerCase() === needle) ??
|
|
CAPITALS.find((c) => c.capital.toLowerCase().includes(needle)) ??
|
|
CAPITALS.find((c) => c.country.toLowerCase().includes(needle))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Forward geocodes typed fields the same way the page's own destination
|
|
* dialog does: structured Nominatim parameters rather than one
|
|
* concatenated string, so a city that also exists elsewhere does not
|
|
* outrank the intended match.
|
|
*/
|
|
async function geocode(fields) {
|
|
const params = new URLSearchParams({ format: "jsonv2", addressdetails: "1", limit: "1" });
|
|
for (const key of ["city", "state", "country"]) {
|
|
if (fields[key]) params.set(key, fields[key]);
|
|
}
|
|
|
|
let response;
|
|
try {
|
|
response = await fetch(`${NOMINATIM}?${params}`, {
|
|
headers: { "user-agent": USER_AGENT, accept: "application/json" },
|
|
});
|
|
} catch (error) {
|
|
throw new CanvasError("geocode_unreachable", `Could not reach the geocoding service: ${error.message}`);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new CanvasError("geocode_failed", `Geocoding failed with status ${response.status}.`);
|
|
}
|
|
|
|
const [match] = await response.json();
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const address = match.address ?? {};
|
|
const city = address.city || address.town || address.village || address.municipality || match.name;
|
|
const state = address.state || address.province || address.region || address.county;
|
|
const code = address.country_code ? address.country_code.toUpperCase() : "";
|
|
const label = [state, city && city !== state ? city : null, code].filter(Boolean).join(", ");
|
|
|
|
return {
|
|
capital: city || "Destination",
|
|
country: address.country || "",
|
|
lat: Number.parseFloat(match.lat),
|
|
lng: Number.parseFloat(match.lon),
|
|
label: label || match.display_name,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Turns fly_to / open input into a destination the page can load.
|
|
*
|
|
* lat + lng flown to directly
|
|
* capital matched against the shipped capitals
|
|
* city/state/country geocoded
|
|
* nothing a random capital, so "take me somewhere" works
|
|
*/
|
|
async function resolveDestination(input) {
|
|
const fields = {
|
|
capital: normalizeText(input?.capital),
|
|
city: normalizeText(input?.city),
|
|
state: normalizeText(input?.state),
|
|
country: normalizeText(input?.country),
|
|
};
|
|
|
|
const lat = typeof input?.lat === "number" ? input.lat : undefined;
|
|
const lng = typeof input?.lng === "number" ? input.lng : undefined;
|
|
|
|
if (lat !== undefined || lng !== undefined) {
|
|
if (lat === undefined || lng === undefined) {
|
|
throw new CanvasError("incomplete_coordinates", "Give both lat and lng, or neither.");
|
|
}
|
|
if (!Number.isFinite(lat) || lat < -MAX_LATITUDE || lat > MAX_LATITUDE) {
|
|
throw new CanvasError(
|
|
"invalid_latitude",
|
|
`lat must be between -${MAX_LATITUDE} and ${MAX_LATITUDE}, the limit of the Web Mercator tile imagery.`,
|
|
);
|
|
}
|
|
if (!Number.isFinite(lng) || lng < -180 || lng > 180) {
|
|
throw new CanvasError("invalid_longitude", "lng must be between -180 and 180.");
|
|
}
|
|
return {
|
|
capital: fields.city || fields.capital || "Waypoint",
|
|
country: fields.country,
|
|
lat,
|
|
lng,
|
|
label: fields.city || fields.capital
|
|
? `${fields.city || fields.capital}${fields.country ? `, ${fields.country}` : ""}`
|
|
: `${Math.abs(lat).toFixed(4)}°${lat >= 0 ? "N" : "S"} ` +
|
|
`${Math.abs(lng).toFixed(4)}°${lng >= 0 ? "E" : "W"}`,
|
|
};
|
|
}
|
|
|
|
if (fields.capital) {
|
|
const capital = findCapital(fields.capital);
|
|
if (capital) return asDestination(capital);
|
|
// Not one of the shipped capitals: treat it as a place name
|
|
fields.city ||= fields.capital;
|
|
}
|
|
|
|
if (fields.city || fields.state || fields.country) {
|
|
const resolved = await geocode(fields);
|
|
if (resolved) return resolved;
|
|
|
|
// A country with no city still has a capital in the shipped list
|
|
const fallback = fields.country && findCapital(fields.country);
|
|
if (fallback) return asDestination(fallback);
|
|
|
|
throw new CanvasError("destination_not_found", "No destination matched that description.");
|
|
}
|
|
|
|
return asDestination(randomCapital());
|
|
}
|
|
|
|
/* ============================================================
|
|
Page serving
|
|
============================================================ */
|
|
|
|
/**
|
|
* Builds the head the page's placeholder expects: a policy, the render
|
|
* configuration, and the host shim. The shim must run before the page's
|
|
* own scripts, which is why it goes in the head rather than after them.
|
|
*/
|
|
function buildHead(entry, nonce) {
|
|
const csp = [
|
|
"default-src 'self'",
|
|
// Satellite tiles are remote images; the terrain fill samples the
|
|
// canvas, so the tile textures are read back and need to be clean
|
|
"img-src 'self' https: data: blob:",
|
|
"style-src 'self' 'unsafe-inline'",
|
|
`script-src 'self' 'nonce-${nonce}'`,
|
|
// 'self' for the event stream below, https: for the geocoder the
|
|
// page's own destination dialog calls
|
|
"connect-src 'self' https:",
|
|
].join("; ");
|
|
|
|
const init = {
|
|
config: entry.config,
|
|
job: entry.job,
|
|
destination: entry.destination,
|
|
};
|
|
|
|
return (
|
|
`<meta http-equiv="Content-Security-Policy" content="${csp}">\n` +
|
|
` <script nonce="${nonce}">\n${hostShim(init)}\n </script>`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Stands in for the VS Code webview host. The page reaches its host
|
|
* through `acquireVsCodeApi()` and a window `message` listener, so
|
|
* defining that API and translating the server's Server-Sent Events into
|
|
* those messages is the whole port.
|
|
*/
|
|
function hostShim(init) {
|
|
return ` (function () {
|
|
var init = ${JSON.stringify(init).replace(/</g, "\\u003c")};
|
|
|
|
// The page reads its render configuration off this global
|
|
window.__flightMapConfig = init.config;
|
|
|
|
function dispatch(message) {
|
|
window.postMessage(message, '*');
|
|
}
|
|
|
|
window.acquireVsCodeApi = function () {
|
|
return {
|
|
postMessage: function (message) {
|
|
if (!message || typeof message !== 'object') return;
|
|
|
|
if (message.command === 'ready') {
|
|
// Replay the opening state once the page is listening
|
|
setTimeout(function () {
|
|
if (init.destination) dispatch({ command: 'flyTo', destination: init.destination });
|
|
if (init.job) dispatch({ command: 'jobStatus', job: init.job });
|
|
}, 0);
|
|
} else if (message.command === 'exportConfig') {
|
|
// No save dialog in a canvas, so the export falls back to the
|
|
// same browser download the standalone page uses
|
|
try {
|
|
var blob = new Blob([message.json], { type: 'application/json' });
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'renderMap.json';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
} catch (e) {
|
|
// Downloads blocked by the host: the live config panel still works
|
|
}
|
|
}
|
|
},
|
|
getState: function () { return undefined; },
|
|
setState: function () {}
|
|
};
|
|
};
|
|
|
|
// Live host -> page channel for agent activity
|
|
try {
|
|
var events = new EventSource('/events');
|
|
events.addEventListener('flyTo', function (event) {
|
|
try { dispatch({ command: 'flyTo', destination: JSON.parse(event.data).destination }); } catch (e) {}
|
|
});
|
|
events.addEventListener('jobStatus', function (event) {
|
|
try { dispatch({ command: 'jobStatus', job: JSON.parse(event.data).job }); } catch (e) {}
|
|
});
|
|
} catch (e) {
|
|
// No EventSource: the simulator still flies, just without agent activity
|
|
}
|
|
})();`;
|
|
}
|
|
|
|
async function renderIndex(entry) {
|
|
const html = await readFile(indexPath, "utf8");
|
|
const nonce = randomBytes(16).toString("base64url");
|
|
return html.replace("<!--{{HOST_HEAD}}-->", buildHead(entry, nonce));
|
|
}
|
|
|
|
async function streamFile(res, filePath) {
|
|
const fileStat = await stat(filePath).catch(() => undefined);
|
|
if (!fileStat?.isFile()) {
|
|
sendNotFound(res);
|
|
return;
|
|
}
|
|
res.writeHead(200, {
|
|
"content-type": contentType(filePath),
|
|
"cache-control": "no-cache",
|
|
});
|
|
const stream = createReadStream(filePath);
|
|
stream.on("error", () => {
|
|
if (!res.headersSent) {
|
|
sendNotFound(res);
|
|
} else {
|
|
res.destroy();
|
|
}
|
|
});
|
|
stream.pipe(res);
|
|
}
|
|
|
|
async function handleRequest(entry, req, res) {
|
|
const url = new URL(req.url ?? "/", entry.url);
|
|
|
|
if (url.pathname === "/events") {
|
|
res.writeHead(200, {
|
|
"content-type": "text/event-stream; charset=utf-8",
|
|
"cache-control": "no-cache",
|
|
connection: "keep-alive",
|
|
});
|
|
entry.clients.add(res);
|
|
// Catch a fresh or reconnecting client up to the live state
|
|
sendSse(res, "jobStatus", { job: entry.job });
|
|
if (entry.destination) {
|
|
sendSse(res, "flyTo", { destination: entry.destination });
|
|
}
|
|
req.on("close", () => entry.clients.delete(res));
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/favicon.ico") {
|
|
await streamFile(res, path.join(assetsRoot, "icon.png"));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
res.writeHead(200, {
|
|
"content-type": "text/html; charset=utf-8",
|
|
"cache-control": "no-cache",
|
|
});
|
|
res.end(await renderIndex(entry));
|
|
return;
|
|
}
|
|
|
|
const staticPath = url.pathname.startsWith("/assets/")
|
|
? resolveUnder(assetsRoot, url.pathname.slice("/assets".length))
|
|
: resolveUnder(gameRoot, url.pathname);
|
|
await streamFile(res, staticPath);
|
|
} catch (error) {
|
|
if (error instanceof CanvasError) {
|
|
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
|
|
res.end(error.message);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function startServer(instanceId, destination) {
|
|
const entry = {
|
|
clients: new Set(),
|
|
config: await readRenderConfig(),
|
|
job: { ...IDLE_JOB },
|
|
destination,
|
|
server: undefined,
|
|
url: undefined,
|
|
};
|
|
|
|
const server = createServer((req, res) => {
|
|
handleRequest(entry, req, res).catch((error) => {
|
|
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
|
res.end(error instanceof Error ? error.message : "Flight map canvas server error");
|
|
});
|
|
});
|
|
entry.server = server;
|
|
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const address = server.address();
|
|
const port = typeof address === "object" && address ? address.port : 0;
|
|
entry.url = `http://127.0.0.1:${port}/`;
|
|
servers.set(instanceId, entry);
|
|
return entry;
|
|
}
|
|
|
|
function getOpenEntry(instanceId) {
|
|
const entry = servers.get(instanceId);
|
|
if (!entry) {
|
|
throw new CanvasError("flight_map_not_open", "Open the Flight Map canvas before invoking this action.");
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
/**
|
|
* Coerces report_job input into the job shape the status strip reads.
|
|
* A call that omits tokens keeps the count already on screen, so the
|
|
* final done=true call settles the job without wiping its total.
|
|
*/
|
|
function normalizeJob(input, previous) {
|
|
if (!input || typeof input !== "object") {
|
|
return { ...IDLE_JOB };
|
|
}
|
|
return {
|
|
working: input.done !== true,
|
|
status: typeof input.status === "string" ? input.status.slice(0, 120) : "",
|
|
tokens:
|
|
typeof input.tokens === "number" && Number.isFinite(input.tokens)
|
|
? Math.max(0, Math.floor(input.tokens))
|
|
: (previous?.tokens ?? 0),
|
|
};
|
|
}
|
|
|
|
const DESTINATION_PROPERTIES = {
|
|
capital: {
|
|
type: "string",
|
|
description:
|
|
"A world capital to fly to, by city or country name (e.g. 'Wellington', 'New Zealand'). Matched against the capitals the canvas ships with.",
|
|
},
|
|
city: {
|
|
type: "string",
|
|
description: "City name for anywhere that is not a shipped capital. Geocoded on the way in.",
|
|
},
|
|
state: {
|
|
type: "string",
|
|
description: "State, region, province, or district that narrows the city.",
|
|
},
|
|
country: {
|
|
type: "string",
|
|
description: "Country that narrows the city.",
|
|
},
|
|
lat: {
|
|
type: "number",
|
|
description: `Latitude in degrees, -${MAX_LATITUDE} to ${MAX_LATITUDE} (the limit of the Web Mercator tile imagery). Give lng as well to skip the geocoder entirely.`,
|
|
minimum: -MAX_LATITUDE,
|
|
maximum: MAX_LATITUDE,
|
|
},
|
|
lng: {
|
|
type: "number",
|
|
description: "Longitude in degrees, -180 to 180. Give lat as well.",
|
|
minimum: -180,
|
|
maximum: 180,
|
|
},
|
|
};
|
|
|
|
await joinSession({
|
|
canvases: [
|
|
createCanvas({
|
|
id: "flight-map-canvas",
|
|
displayName: "Flight Map",
|
|
description:
|
|
"Fly a first-person satellite terrain map of the world while agents work. The agent can send the flight anywhere and report what it is doing.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: DESTINATION_PROPERTIES,
|
|
additionalProperties: false,
|
|
},
|
|
actions: [
|
|
{
|
|
name: "fly_to",
|
|
description:
|
|
"Send the flight to a destination. Give a capital by name, a city with an optional state and country, or a raw lat/lng. Called with no input it picks a random world capital, which is the way to say 'take me somewhere else'.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: DESTINATION_PROPERTIES,
|
|
additionalProperties: false,
|
|
},
|
|
handler: async (ctx) => {
|
|
const entry = getOpenEntry(ctx.instanceId);
|
|
const destination = await resolveDestination(ctx.input);
|
|
// Held so a reload, or a client that connects later,
|
|
// arrives at the same place the agent sent the flight
|
|
entry.destination = destination;
|
|
broadcast(entry, "flyTo", { destination });
|
|
return destination;
|
|
},
|
|
},
|
|
{
|
|
name: "report_job",
|
|
description:
|
|
"Report your current job status to the flight map. Call it when you start a chat request, again on each new step, and once more with done=true when finished. The status shows on a strip under the HUD and the token count runs beside it.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
status: {
|
|
type: "string",
|
|
description:
|
|
"Short status text to show under the HUD (e.g. 'reading the codebase', 'rewriting the parser').",
|
|
},
|
|
tokens: {
|
|
type: "number",
|
|
description: "Approximate tokens consumed by the current job so far.",
|
|
},
|
|
done: {
|
|
type: "boolean",
|
|
description:
|
|
"Set true when the job is finished; the activity indicator stills and the last status stays readable.",
|
|
},
|
|
},
|
|
required: ["status"],
|
|
additionalProperties: false,
|
|
},
|
|
handler: (ctx) => {
|
|
const entry = getOpenEntry(ctx.instanceId);
|
|
entry.job = normalizeJob(ctx.input, entry.job);
|
|
broadcast(entry, "jobStatus", { job: entry.job });
|
|
return { job: entry.job };
|
|
},
|
|
},
|
|
],
|
|
open: async (ctx) => {
|
|
const hasInput = ctx.input && Object.keys(ctx.input).length > 0;
|
|
// Only resolve when asked for somewhere; otherwise the page
|
|
// opens on its own welcome screen and the menu picks
|
|
const destination = hasInput ? await resolveDestination(ctx.input) : undefined;
|
|
|
|
let entry = servers.get(ctx.instanceId);
|
|
if (!entry) {
|
|
entry = await startServer(ctx.instanceId, destination);
|
|
} else if (destination) {
|
|
entry.destination = destination;
|
|
broadcast(entry, "flyTo", { destination });
|
|
}
|
|
|
|
return {
|
|
title: "Flight Map",
|
|
status: entry.job.working && entry.job.status
|
|
? entry.job.status
|
|
: (entry.destination?.label ?? "Pick a destination"),
|
|
url: entry.url,
|
|
};
|
|
},
|
|
onClose: async (ctx) => {
|
|
const entry = servers.get(ctx.instanceId);
|
|
if (!entry) return;
|
|
servers.delete(ctx.instanceId);
|
|
for (const client of entry.clients) {
|
|
client.end();
|
|
}
|
|
await new Promise((resolve) => entry.server.close(() => resolve()));
|
|
},
|
|
}),
|
|
],
|
|
});
|