Merge upstream/main into add-daily-focus-board-skill
Resolve the .codespellrc conflict by retaining both the daily-focus-board checkin key and upstream ACI term. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fd1eae93-cc9f-4777-812c-a2a9872e1c2b
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "flight-map-canvas",
|
||||
"description": "A GitHub Copilot canvas that generates a view where Google Maps can be explored using 3D controls, as if a flight simulator. Agents can send the flight anywhere and report what they are working on.",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "John Haugabook",
|
||||
"url": "https://github.com/jhauga"
|
||||
},
|
||||
"keywords": [
|
||||
"copilot-canvas",
|
||||
"flight-simulator",
|
||||
"geography",
|
||||
"google-maps",
|
||||
"interactive-canvas",
|
||||
"session-breaks",
|
||||
"threejs"
|
||||
],
|
||||
"logo": "assets/preview.png",
|
||||
"extensions": "."
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
# Flight Map Canvas
|
||||
|
||||
A GitHub Copilot canvas that generates a view where Google Maps can be explored using 3D controls, as if a flight simulator. No 3D plane model as the focus is the aerial landscape. Fly around in 1st person for several minutes, starting from a capital city while Copilot works.
|
||||
|
||||
It is a canvas port of the [Flight Map VSCode extension](https://github.com/isocialPractice/vscode-flight-map). Satellite imagery tiles stream in around the camera as it moves, unloaded gaps are filled with color sampled from the viewport, and drifting procedural clouds sit over the fill. The HUD reads heading, speed, altitude, pitch, and live coordinates, with the city, region, and country under the camera looked up as it flies.
|
||||
|
||||
While an agent works in the session, it can send the flight somewhere new and report what it is doing on a status strip under the HUD.
|
||||
|
||||
## Files
|
||||
|
||||
- `extension.mjs` — canvas declaration, loopback game server, the host shim it injects into the page, destination resolution, and agent actions.
|
||||
- `game/` — the simulator itself, byte for byte the VSCode extension's `media/` folder. `index.html` is its `flightMap.html`; the rest is the Three.js scene, tile loader, flight controls, terrain fill, HUD, and configuration panel.
|
||||
- `game/vendor/three.min.js` — vendored Three.js r128, so the only remote requests are the ones the simulation makes for imagery and place names.
|
||||
- `renderMap.json` — the render configuration the canvas starts on.
|
||||
- `assets/` — app icon and `preview.png` for the extensions gallery.
|
||||
- `package.json` — declares the Copilot SDK dependency and ESM entry point.
|
||||
- `copilot-extension.json` — Copilot extension name/version metadata.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js 20.19 or newer**, because the Copilot SDK requires `node ^20.19.0 || >=22.12.0`.
|
||||
- A WebGL-capable canvas surface; the scene is rendered through Three.js.
|
||||
- Network access. Satellite tiles, the location label, and destination lookups are all fetched at runtime.
|
||||
- The GitHub Copilot app canvas / UI-extensions experiment enabled.
|
||||
|
||||
## Install
|
||||
|
||||
Drop this folder at `~/.copilot/extensions/flight-map-canvas/` for user scope, or in a repository at `.github/extensions/flight-map-canvas/` for project scope. Then install dependencies from inside the copied folder:
|
||||
|
||||
```sh
|
||||
# User scope
|
||||
cd ~/.copilot/extensions/flight-map-canvas
|
||||
|
||||
# Or project scope, from the repository root
|
||||
cd .github/extensions/flight-map-canvas
|
||||
|
||||
npm install
|
||||
```
|
||||
|
||||
Reload extensions in the GitHub Copilot app, then open the `flight-map-canvas` canvas. Pick a destination from the menu, click the view to start flying, and use the controls below.
|
||||
|
||||
The canvas accepts optional open inputs, resolved the same way `fly_to` resolves them:
|
||||
|
||||
| Input | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `capital` | string | A world capital by city or country name. Matched against the capitals the canvas ships with. |
|
||||
| `city` | string | City name for anywhere that is not a shipped capital. Geocoded on the way in. |
|
||||
| `state` | string | State, region, province, or district that narrows the city. |
|
||||
| `country` | string | Country that narrows the city. |
|
||||
| `lat` / `lng` | number | Raw coordinates, which skip the geocoder entirely. Latitude runs -85.0511 to 85.0511, the limit of the Web Mercator tile imagery; longitude runs -180 to 180. Give both or neither. |
|
||||
|
||||
Opened with no input, the canvas starts on its own welcome screen and waits for a destination.
|
||||
|
||||
## Controls
|
||||
|
||||
| Input | Action |
|
||||
| --- | --- |
|
||||
| `W` / `S` or `Up` / `Down` | Throttle |
|
||||
| Mouse (after clicking the view) | Pitch and yaw |
|
||||
| `A` / `D` or `Left` / `Right` | Roll |
|
||||
| `Q` / `E` | Rudder yaw |
|
||||
| `R` / `F` | Climb / descend |
|
||||
| `Space` | Auto-level |
|
||||
| `Shift` | Boost |
|
||||
| `P` | Pause / resume |
|
||||
| `Esc` | Release the view |
|
||||
|
||||
Mouse look prefers pointer lock. If the canvas host refuses it, the controls fall back to drag look: hold the left mouse button and move to steer. The on-screen help says which mode is active.
|
||||
|
||||
The **Destination** menu lists the world capitals grouped by continent and opens with **Input Destination**, which takes a typed state/region and city plus a country from a menu, then lists what matched so one can be selected. **Config** opens live rendering sliders, and **Export JSON** downloads the current configuration as `renderMap.json`.
|
||||
|
||||
## Agent actions
|
||||
|
||||
- `fly_to { capital?, city?, state?, country?, lat?, lng? }` — send the flight to a destination. A capital is matched against the shipped list, a city is geocoded, and a lat/lng pair is flown to directly. Called with no input it picks a random world capital, which is the way to say "take me somewhere else".
|
||||
- `report_job { status, tokens?, done? }` — report the current job step. The status shows on a strip under the HUD and the token count runs beside it. Call it when work starts, again on each new step, and once more with `done: true` when finished.
|
||||
|
||||
To have the agent narrate itself automatically, reference these actions from a `.github/copilot-instructions.md` so it calls `report_job` on each step of a chat request.
|
||||
|
||||
## How the port works
|
||||
|
||||
The simulator under `game/` is unchanged from the VSCode extension. That page was already written to run in two places, and it reaches whatever is hosting it through one seam: an `acquireVsCodeApi()` object for messaging, and a `<!--{{HOST_HEAD}}-->` placeholder in its `<head>` that the host fills with a content security policy and its render configuration.
|
||||
|
||||
`extension.mjs` fills that seam for the canvas:
|
||||
|
||||
- A loopback HTTP server serves the page and its assets, replacing the placeholder with a policy, the render configuration read from `renderMap.json`, and a shim that defines `acquireVsCodeApi()`.
|
||||
- Agent activity is streamed to the page over Server-Sent Events at `/events`. The shim translates those events into the exact `flyTo` and `jobStatus` commands the page already registers handlers for.
|
||||
- The page's export button has no save dialog to reach here, so the shim falls back to the browser download the standalone page uses.
|
||||
- Destinations are resolved server-side before broadcasting, against the page's own `capitals.js` for shipped capitals and OpenStreetMap Nominatim for everything else.
|
||||
|
||||
This mirrors the [`backrooms-canvas`](../backrooms-canvas) and [`arcade-canvas`](../arcade-canvas) architecture: a static frontend served from a loopback server, with the agent driving it through canvas actions.
|
||||
|
||||
## Development
|
||||
|
||||
The port is a copy, not a fork. Everything under `game/` comes from the VSCode extension's `media/` folder, so edits belong there.
|
||||
|
||||
In that repository the two are held together by symlinks, which means there is only ever one copy to edit and the port cannot go stale. Submission needs real files, though, because a link pointing at a sibling repository resolves to nothing anywhere else, so the stage command resolves every link on the way out:
|
||||
|
||||
```sh
|
||||
# In the vscode-flight-map repository
|
||||
npm run canvas:link # create or repair the symlinks
|
||||
npm run canvas:check # verify them; non-zero when stale
|
||||
npm run canvas:stage -- <destination> # real-file copy for submission
|
||||
```
|
||||
|
||||
`media/flightMap.html` is linked as `game/index.html`; nothing else is renamed and no file is rewritten. To run the simulator outside a canvas while working on it, open `media/flightMap.html` in a browser.
|
||||
|
||||
## Credits
|
||||
|
||||
- Source extension and canvas port: [vscode-flight-map](https://github.com/isocialPractice/vscode-flight-map) by John Haugabook.
|
||||
- Satellite imagery: Google Maps tiles, with Esri World Imagery as the fallback provider. The imagery is served by, and remains the property of, those providers; this canvas only displays it, subject to their respective terms of service.
|
||||
- Place names and destination lookups: [OpenStreetMap Nominatim](https://nominatim.openstreetmap.org/). Data © OpenStreetMap contributors, available under the [Open Database License](https://www.openstreetmap.org/copyright).
|
||||
- 3D rendering: [Three.js](https://threejs.org/) r128.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 255 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "flight-map-canvas",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
/**
|
||||
* 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()));
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**************************************************************
|
||||
* World Capitals organized by continent/region. *
|
||||
* Each capital includes country, city name, and coordinates. *
|
||||
*************************************************************/
|
||||
var CAPITALS_BY_REGION = [
|
||||
{
|
||||
region: "North America",
|
||||
capitals: [
|
||||
{ country: "United States", capital: "Washington, D.C.", lat: 38.8951, lng: -77.0364 },
|
||||
{ country: "Canada", capital: "Ottawa", lat: 45.4215, lng: -75.6972 },
|
||||
{ country: "Mexico", capital: "Mexico City", lat: 19.4326, lng: -99.1332 },
|
||||
{ country: "Cuba", capital: "Havana", lat: 23.1136, lng: -82.3666 },
|
||||
{ country: "Guatemala", capital: "Guatemala City", lat: 14.6349, lng: -90.5069 },
|
||||
{ country: "Costa Rica", capital: "San Jose", lat: 9.9281, lng: -84.0907 },
|
||||
{ country: "Panama", capital: "Panama City", lat: 8.9824, lng: -79.5199 },
|
||||
{ country: "Jamaica", capital: "Kingston", lat: 18.0179, lng: -76.8099 }
|
||||
]
|
||||
},
|
||||
{
|
||||
region: "South America",
|
||||
capitals: [
|
||||
{ country: "Brazil", capital: "Brasilia", lat: -15.7975, lng: -47.8919 },
|
||||
{ country: "Argentina", capital: "Buenos Aires", lat: -34.6037, lng: -58.3816 },
|
||||
{ country: "Chile", capital: "Santiago", lat: -33.4489, lng: -70.6693 },
|
||||
{ country: "Colombia", capital: "Bogota", lat: 4.7110, lng: -74.0721 },
|
||||
{ country: "Peru", capital: "Lima", lat: -12.0464, lng: -77.0428 },
|
||||
{ country: "Venezuela", capital: "Caracas", lat: 10.4806, lng: -66.9036 },
|
||||
{ country: "Ecuador", capital: "Quito", lat: -0.1807, lng: -78.4678 },
|
||||
{ country: "Uruguay", capital: "Montevideo", lat: -34.9011, lng: -56.1645 }
|
||||
]
|
||||
},
|
||||
{
|
||||
region: "Europe",
|
||||
capitals: [
|
||||
{ country: "United Kingdom", capital: "London", lat: 51.5074, lng: -0.1278 },
|
||||
{ country: "France", capital: "Paris", lat: 48.8566, lng: 2.3522 },
|
||||
{ country: "Germany", capital: "Berlin", lat: 52.5200, lng: 13.4050 },
|
||||
{ country: "Italy", capital: "Rome", lat: 41.9028, lng: 12.4964 },
|
||||
{ country: "Spain", capital: "Madrid", lat: 40.4168, lng: -3.7038 },
|
||||
{ country: "Russia", capital: "Moscow", lat: 55.7558, lng: 37.6173 },
|
||||
{ country: "Netherlands", capital: "Amsterdam", lat: 52.3676, lng: 4.9041 },
|
||||
{ country: "Greece", capital: "Athens", lat: 37.9838, lng: 23.7275 },
|
||||
{ country: "Sweden", capital: "Stockholm", lat: 59.3293, lng: 18.0686 },
|
||||
{ country: "Norway", capital: "Oslo", lat: 59.9139, lng: 10.7522 },
|
||||
{ country: "Portugal", capital: "Lisbon", lat: 38.7223, lng: -9.1393 },
|
||||
{ country: "Czech Republic", capital: "Prague", lat: 50.0755, lng: 14.4378 },
|
||||
{ country: "Austria", capital: "Vienna", lat: 48.2082, lng: 16.3738 },
|
||||
{ country: "Switzerland", capital: "Bern", lat: 46.9480, lng: 7.4474 },
|
||||
{ country: "Poland", capital: "Warsaw", lat: 52.2297, lng: 21.0122 },
|
||||
{ country: "Turkey", capital: "Ankara", lat: 39.9334, lng: 32.8597 }
|
||||
]
|
||||
},
|
||||
{
|
||||
region: "Asia",
|
||||
capitals: [
|
||||
{ country: "Japan", capital: "Tokyo", lat: 35.6762, lng: 139.6503 },
|
||||
{ country: "China", capital: "Beijing", lat: 39.9042, lng: 116.4074 },
|
||||
{ country: "India", capital: "New Delhi", lat: 28.6139, lng: 77.2090 },
|
||||
{ country: "South Korea", capital: "Seoul", lat: 37.5665, lng: 126.9780 },
|
||||
{ country: "Thailand", capital: "Bangkok", lat: 13.7563, lng: 100.5018 },
|
||||
{ country: "Vietnam", capital: "Hanoi", lat: 21.0285, lng: 105.8542 },
|
||||
{ country: "Indonesia", capital: "Jakarta", lat: -6.2088, lng: 106.8456 },
|
||||
{ country: "Philippines", capital: "Manila", lat: 14.5995, lng: 120.9842 },
|
||||
{ country: "Singapore", capital: "Singapore", lat: 1.3521, lng: 103.8198 },
|
||||
{ country: "Malaysia", capital: "Kuala Lumpur", lat: 3.1390, lng: 101.6869 },
|
||||
{ country: "Pakistan", capital: "Islamabad", lat: 33.6844, lng: 73.0479 },
|
||||
{ country: "Israel", capital: "Jerusalem", lat: 31.7683, lng: 35.2137 },
|
||||
{ country: "UAE", capital: "Abu Dhabi", lat: 24.4539, lng: 54.3773 },
|
||||
{ country: "Saudi Arabia", capital: "Riyadh", lat: 24.7136, lng: 46.6753 }
|
||||
]
|
||||
},
|
||||
{
|
||||
region: "Africa",
|
||||
capitals: [
|
||||
{ country: "Egypt", capital: "Cairo", lat: 30.0444, lng: 31.2357 },
|
||||
{ country: "South Africa", capital: "Pretoria", lat: -25.7479, lng: 28.2293 },
|
||||
{ country: "Kenya", capital: "Nairobi", lat: -1.2921, lng: 36.8219 },
|
||||
{ country: "Nigeria", capital: "Abuja", lat: 9.0579, lng: 7.4951 },
|
||||
{ country: "Morocco", capital: "Rabat", lat: 34.0209, lng: -6.8416 },
|
||||
{ country: "Ethiopia", capital: "Addis Ababa", lat: 9.0250, lng: 38.7469 },
|
||||
{ country: "Tanzania", capital: "Dodoma", lat: -6.1630, lng: 35.7516 },
|
||||
{ country: "Ghana", capital: "Accra", lat: 5.6037, lng: -0.1870 }
|
||||
]
|
||||
},
|
||||
{
|
||||
region: "Oceania",
|
||||
capitals: [
|
||||
{ country: "Australia", capital: "Canberra", lat: -35.2809, lng: 149.1300 },
|
||||
{ country: "New Zealand", capital: "Wellington", lat: -41.2865, lng: 174.7762 },
|
||||
{ country: "Fiji", capital: "Suva", lat: -18.1416, lng: 178.4419 },
|
||||
{ country: "Papua New Guinea", capital: "Port Moresby", lat: -6.3149, lng: 143.9556 }
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,310 @@
|
||||
/***********************************************************************
|
||||
* DestinationInput - Keyboard entry for destinations that are not one *
|
||||
* of the built-in capitals. *
|
||||
* *
|
||||
* Opened from the "Input Destination" entry at the top of the *
|
||||
* destination menu. The dialog runs in two stages: *
|
||||
* *
|
||||
* 1. Form - state/region and city are typed, country is picked *
|
||||
* from the menu. Any one of the three is enough. *
|
||||
* 2. Results - the matches the geocoder returned, one per row. *
|
||||
* Picking one and pressing Select flies there. *
|
||||
* *
|
||||
* The dialog owns the keyboard while it is open: flight input already *
|
||||
* ignores events from form fields, and the shortcuts below stop at *
|
||||
* the dialog so a typed letter never reaches the simulator. *
|
||||
***********************************************************************/
|
||||
var DestinationInput = (function () {
|
||||
|
||||
var dialog, formStage, resultsStage;
|
||||
var stateInput, cityInput, countrySelect;
|
||||
var formMessage, resultsMessage, resultList;
|
||||
var searchBtn, selectBtn;
|
||||
|
||||
var onSelect = null;
|
||||
var onOpen = null;
|
||||
var onClose = null;
|
||||
|
||||
var results = [];
|
||||
var activeIndex = -1;
|
||||
var searching = false;
|
||||
var isOpen = false;
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
Country menu
|
||||
------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Fills the country menu from the capitals dataset, which is the
|
||||
* only list of country names the page ships with. Leaving the menu
|
||||
* on its first entry searches without a country filter.
|
||||
*/
|
||||
function populateCountries() {
|
||||
if (typeof CAPITALS_BY_REGION === 'undefined') return;
|
||||
|
||||
var seen = {};
|
||||
var names = [];
|
||||
|
||||
for (var i = 0; i < CAPITALS_BY_REGION.length; i++) {
|
||||
var capitals = CAPITALS_BY_REGION[i].capitals;
|
||||
for (var j = 0; j < capitals.length; j++) {
|
||||
var country = capitals[j].country;
|
||||
if (seen[country]) continue;
|
||||
seen[country] = true;
|
||||
names.push(country);
|
||||
}
|
||||
}
|
||||
|
||||
names.sort();
|
||||
|
||||
for (var k = 0; k < names.length; k++) {
|
||||
var option = document.createElement('option');
|
||||
option.value = names[k];
|
||||
option.textContent = names[k];
|
||||
countrySelect.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
Stage switching
|
||||
------------------------------------------------------------ */
|
||||
|
||||
function showForm() {
|
||||
formStage.classList.remove('hidden');
|
||||
resultsStage.classList.add('hidden');
|
||||
resultsMessage.textContent = '';
|
||||
}
|
||||
|
||||
function showResults() {
|
||||
formStage.classList.add('hidden');
|
||||
resultsStage.classList.remove('hidden');
|
||||
formMessage.textContent = '';
|
||||
}
|
||||
|
||||
function setSearching(active) {
|
||||
searching = active;
|
||||
searchBtn.disabled = active;
|
||||
searchBtn.textContent = active ? 'Searching...' : 'Search';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
Results list
|
||||
------------------------------------------------------------ */
|
||||
|
||||
function renderResults() {
|
||||
resultList.innerHTML = '';
|
||||
|
||||
for (var i = 0; i < results.length; i++) {
|
||||
var row = document.createElement('li');
|
||||
row.className = 'dest-result';
|
||||
// Focus stays on the listbox, so each option needs an id for
|
||||
// the list's aria-activedescendant to point a reader at it
|
||||
row.id = 'dest-result-' + i;
|
||||
row.setAttribute('role', 'option');
|
||||
row.setAttribute('aria-selected', 'false');
|
||||
row.dataset.index = String(i);
|
||||
row.textContent = results[i].label;
|
||||
resultList.appendChild(row);
|
||||
}
|
||||
|
||||
setActiveIndex(results.length ? 0 : -1);
|
||||
}
|
||||
|
||||
function setActiveIndex(index) {
|
||||
activeIndex = index;
|
||||
var rows = resultList.children;
|
||||
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var active = i === index;
|
||||
rows[i].classList.toggle('active', active);
|
||||
rows[i].setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
}
|
||||
|
||||
selectBtn.disabled = index < 0;
|
||||
|
||||
// Point the listbox at the option the arrow keys landed on, and
|
||||
// drop the reference entirely when nothing is active
|
||||
if (index >= 0 && rows[index]) {
|
||||
resultList.setAttribute('aria-activedescendant', rows[index].id);
|
||||
if (rows[index].scrollIntoView) {
|
||||
rows[index].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
} else {
|
||||
resultList.removeAttribute('aria-activedescendant');
|
||||
}
|
||||
}
|
||||
|
||||
function moveActive(step) {
|
||||
if (!results.length) return;
|
||||
var next = activeIndex + step;
|
||||
if (next < 0) next = 0;
|
||||
if (next > results.length - 1) next = results.length - 1;
|
||||
setActiveIndex(next);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
Actions
|
||||
------------------------------------------------------------ */
|
||||
|
||||
function runSearch() {
|
||||
if (searching) return;
|
||||
|
||||
formMessage.textContent = '';
|
||||
setSearching(true);
|
||||
|
||||
Geocoder.search({
|
||||
state: stateInput.value,
|
||||
city: cityInput.value,
|
||||
country: countrySelect.value
|
||||
}, function (error, found) {
|
||||
setSearching(false);
|
||||
|
||||
if (error) {
|
||||
formMessage.textContent = error;
|
||||
return;
|
||||
}
|
||||
|
||||
results = found;
|
||||
renderResults();
|
||||
showResults();
|
||||
|
||||
resultsMessage.textContent = found.length
|
||||
? ''
|
||||
: 'No matching destination was found. Cancel to adjust the search.';
|
||||
|
||||
if (found.length) resultList.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function commitSelection() {
|
||||
if (activeIndex < 0 || !results[activeIndex]) return;
|
||||
var chosen = results[activeIndex];
|
||||
close();
|
||||
if (onSelect) onSelect(chosen);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
Open / close
|
||||
------------------------------------------------------------ */
|
||||
|
||||
function open() {
|
||||
if (isOpen) return;
|
||||
isOpen = true;
|
||||
|
||||
results = [];
|
||||
activeIndex = -1;
|
||||
// Rows from the previous search are stale the moment the form
|
||||
// reopens, so nothing should still be pointing at one of them
|
||||
resultList.removeAttribute('aria-activedescendant');
|
||||
formMessage.textContent = '';
|
||||
setSearching(false);
|
||||
showForm();
|
||||
|
||||
dialog.classList.remove('hidden');
|
||||
if (onOpen) onOpen();
|
||||
stateInput.focus();
|
||||
stateInput.select();
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!isOpen) return;
|
||||
isOpen = false;
|
||||
|
||||
dialog.classList.add('hidden');
|
||||
if (onClose) onClose();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
Wiring
|
||||
------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Binds the dialog. options:
|
||||
* onSelect(destination) - a result the user picked
|
||||
* onOpen() - the dialog became visible
|
||||
* onClose() - the dialog was dismissed or committed
|
||||
*/
|
||||
function init(options) {
|
||||
options = options || {};
|
||||
onSelect = options.onSelect || null;
|
||||
onOpen = options.onOpen || null;
|
||||
onClose = options.onClose || null;
|
||||
|
||||
dialog = document.getElementById('destination-dialog');
|
||||
if (!dialog) return;
|
||||
|
||||
formStage = document.getElementById('destination-form');
|
||||
resultsStage = document.getElementById('destination-results');
|
||||
stateInput = document.getElementById('dest-state');
|
||||
cityInput = document.getElementById('dest-city');
|
||||
countrySelect = document.getElementById('dest-country');
|
||||
formMessage = document.getElementById('dest-form-message');
|
||||
resultsMessage = document.getElementById('dest-results-message');
|
||||
resultList = document.getElementById('dest-result-list');
|
||||
searchBtn = document.getElementById('dest-search');
|
||||
selectBtn = document.getElementById('dest-select');
|
||||
|
||||
populateCountries();
|
||||
|
||||
searchBtn.addEventListener('click', runSearch);
|
||||
selectBtn.addEventListener('click', commitSelection);
|
||||
document.getElementById('dest-cancel').addEventListener('click', close);
|
||||
document.getElementById('dest-results-cancel').addEventListener('click', close);
|
||||
|
||||
// Enter submits from any field in the form stage
|
||||
formStage.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
runSearch();
|
||||
}
|
||||
});
|
||||
|
||||
// Click a row to highlight it, double click to fly straight there
|
||||
resultList.addEventListener('click', function (e) {
|
||||
var row = e.target.closest ? e.target.closest('.dest-result') : null;
|
||||
if (!row) return;
|
||||
setActiveIndex(parseInt(row.dataset.index, 10));
|
||||
});
|
||||
|
||||
resultList.addEventListener('dblclick', function (e) {
|
||||
var row = e.target.closest ? e.target.closest('.dest-result') : null;
|
||||
if (!row) return;
|
||||
setActiveIndex(parseInt(row.dataset.index, 10));
|
||||
commitSelection();
|
||||
});
|
||||
|
||||
resultList.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
moveActive(1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
moveActive(-1);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitSelection();
|
||||
}
|
||||
});
|
||||
|
||||
// Escape closes from anywhere inside the dialog, and clicking the
|
||||
// backdrop outside the card does the same
|
||||
dialog.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
dialog.addEventListener('mousedown', function (e) {
|
||||
if (e.target === dialog) close();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
init: init,
|
||||
open: open,
|
||||
close: close,
|
||||
isOpen: function () { return isOpen; }
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,385 @@
|
||||
/*******************************************************************
|
||||
* FlightControls - Flight simulator camera controls for Three.js. *
|
||||
* *
|
||||
* Controls: *
|
||||
* Mouse (pointer lock): Pitch and Yaw *
|
||||
* W / Up Arrow: Increase speed *
|
||||
* S / Down Arrow: Decrease speed *
|
||||
* A / Left Arrow: Roll left *
|
||||
* D / Right Arrow: Roll right *
|
||||
* Q: Yaw left (rudder) *
|
||||
* E: Yaw right (rudder) *
|
||||
* R: Climb *
|
||||
* F: Descend *
|
||||
* Space: Auto-level (stabilize) *
|
||||
* Shift: Boost speed *
|
||||
* *
|
||||
* Look modes: *
|
||||
* pointer - pointer lock captures the cursor (preferred) *
|
||||
* drag - hold the left mouse button and drag to look *
|
||||
* *
|
||||
* Drag mode is the fallback for hosts that deny pointer lock, *
|
||||
* such as an embedded panel whose frame withholds the permission. *
|
||||
* Escape releases drag mode the way it releases pointer lock. *
|
||||
******************************************************************/
|
||||
|
||||
/**
|
||||
* True when a key event belongs to the page rather than to the
|
||||
* simulator: the user is typing, choosing a value, or working inside a
|
||||
* dialog. Flight input listens on the document, so without this guard a
|
||||
* space typed into a destination field would trigger auto-level and
|
||||
* have its default suppressed.
|
||||
*/
|
||||
function isTextEntryTarget(target) {
|
||||
if (!target || !target.tagName) return false;
|
||||
|
||||
var tag = target.tagName.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
|
||||
if (target.isContentEditable === true) return true;
|
||||
|
||||
return !!(target.closest && target.closest('[role="dialog"]'));
|
||||
}
|
||||
|
||||
function FlightControls(camera, domElement) {
|
||||
this.camera = camera;
|
||||
this.domElement = domElement;
|
||||
|
||||
// Position and orientation
|
||||
this.position = camera.position.clone();
|
||||
this.quaternion = new THREE.Quaternion();
|
||||
|
||||
// Speed
|
||||
this.speed = 0;
|
||||
this.targetSpeed = 0;
|
||||
this.minSpeed = 0;
|
||||
this.maxSpeed = 12;
|
||||
this.boostMultiplier = 3;
|
||||
this.acceleration = 4.0;
|
||||
this.deceleration = 3.0;
|
||||
|
||||
// Rotation
|
||||
this.rollSpeed = 1.8;
|
||||
this.yawSpeed = 1.0;
|
||||
this.mouseSensitivity = 0.002;
|
||||
|
||||
// Altitude
|
||||
this.verticalSpeed = 0;
|
||||
this.verticalAccel = 4.0;
|
||||
this.minAltitude = 8;
|
||||
|
||||
// Input state
|
||||
this.keys = {};
|
||||
this.mouseMovementX = 0;
|
||||
this.mouseMovementY = 0;
|
||||
this.isPointerLocked = false;
|
||||
this.isEnabled = false;
|
||||
|
||||
// Look mode: 'pointer' until a lock request is refused, then 'drag'
|
||||
this.lookMode = 'pointer';
|
||||
this.isDragging = false;
|
||||
this.lockTimeoutMs = 700;
|
||||
this._lockTimer = null;
|
||||
|
||||
// Auto-level
|
||||
this.autoLevel = false;
|
||||
this.autoLevelSpeed = 3.0;
|
||||
|
||||
this._init();
|
||||
}
|
||||
|
||||
FlightControls.prototype._init = function () {
|
||||
var self = this;
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (isTextEntryTarget(e.target)) return;
|
||||
self.keys[e.code] = true;
|
||||
if (e.code === 'Space') {
|
||||
self.autoLevel = true;
|
||||
e.preventDefault();
|
||||
}
|
||||
// Pointer lock exits on Escape by itself; drag mode needs releasing
|
||||
if (e.code === 'Escape' && self.lookMode === 'drag') {
|
||||
self._release();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keyup', function (e) {
|
||||
if (isTextEntryTarget(e.target)) return;
|
||||
self.keys[e.code] = false;
|
||||
if (e.code === 'Space') {
|
||||
self.autoLevel = false;
|
||||
}
|
||||
});
|
||||
|
||||
this.domElement.addEventListener('click', function () {
|
||||
if (self.isEnabled) return;
|
||||
if (self.lookMode === 'drag') {
|
||||
self._engage();
|
||||
} else if (!self.isPointerLocked) {
|
||||
self._requestLock();
|
||||
}
|
||||
});
|
||||
|
||||
this.domElement.addEventListener('mousedown', function (e) {
|
||||
if (self.lookMode !== 'drag' || !self.isEnabled) return;
|
||||
if (e.button !== 0) return;
|
||||
self.isDragging = true;
|
||||
// Suppress the text selection drag that would otherwise start
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', function () {
|
||||
self.isDragging = false;
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', function (e) {
|
||||
var capturing = self.isPointerLocked ||
|
||||
(self.lookMode === 'drag' && self.isEnabled && self.isDragging);
|
||||
if (!capturing) return;
|
||||
self.mouseMovementX += e.movementX || 0;
|
||||
self.mouseMovementY += e.movementY || 0;
|
||||
});
|
||||
|
||||
document.addEventListener('pointerlockchange', function () {
|
||||
self.isPointerLocked = document.pointerLockElement === self.domElement;
|
||||
clearTimeout(self._lockTimer);
|
||||
if (self.isPointerLocked) {
|
||||
self.lookMode = 'pointer';
|
||||
self._engage();
|
||||
} else {
|
||||
self._release();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('pointerlockerror', function () {
|
||||
self._fallbackToDrag();
|
||||
});
|
||||
|
||||
this.domElement.addEventListener('contextmenu', function (e) {
|
||||
e.preventDefault();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Requests pointer lock, falling back to drag look when the host
|
||||
* refuses the request, throws, or ignores it without an error event.
|
||||
*/
|
||||
FlightControls.prototype._requestLock = function () {
|
||||
var self = this;
|
||||
|
||||
if (!this.domElement.requestPointerLock) {
|
||||
this._fallbackToDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
var request;
|
||||
try {
|
||||
request = this.domElement.requestPointerLock();
|
||||
} catch (e) {
|
||||
this._fallbackToDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
// Newer browsers resolve a promise; older ones return undefined and
|
||||
// report failure through the pointerlockerror event
|
||||
if (request && typeof request.catch === 'function') {
|
||||
request.catch(function () {
|
||||
self._fallbackToDrag();
|
||||
});
|
||||
}
|
||||
|
||||
clearTimeout(this._lockTimer);
|
||||
this._lockTimer = setTimeout(function () {
|
||||
if (!self.isPointerLocked) self._fallbackToDrag();
|
||||
}, this.lockTimeoutMs);
|
||||
};
|
||||
|
||||
/**
|
||||
* Switches to drag look and engages immediately, so the click that
|
||||
* asked for pointer lock still starts the flight.
|
||||
*/
|
||||
FlightControls.prototype._fallbackToDrag = function () {
|
||||
clearTimeout(this._lockTimer);
|
||||
if (this.lookMode !== 'drag') {
|
||||
this.lookMode = 'drag';
|
||||
}
|
||||
this._engage();
|
||||
};
|
||||
|
||||
/**
|
||||
* Enables flight input and notifies the UI.
|
||||
*/
|
||||
FlightControls.prototype._engage = function () {
|
||||
if (this.isEnabled) return;
|
||||
this.isEnabled = true;
|
||||
this._dispatch(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Disables flight input and clears any state that could read as stuck.
|
||||
*/
|
||||
FlightControls.prototype._release = function () {
|
||||
if (!this.isEnabled) return;
|
||||
this.isEnabled = false;
|
||||
this.isDragging = false;
|
||||
|
||||
// Keyup events can be missed while released; clear input state
|
||||
// so no key reads as stuck on the next engage
|
||||
this.keys = {};
|
||||
this.autoLevel = false;
|
||||
this.mouseMovementX = 0;
|
||||
this.mouseMovementY = 0;
|
||||
|
||||
this._dispatch(false);
|
||||
};
|
||||
|
||||
FlightControls.prototype._dispatch = function (locked) {
|
||||
document.dispatchEvent(new CustomEvent('flightcontrols', {
|
||||
detail: { locked: locked, mode: this.lookMode }
|
||||
}));
|
||||
};
|
||||
|
||||
FlightControls.prototype.update = function (delta) {
|
||||
if (!this.isEnabled) return;
|
||||
delta = Math.min(delta, 0.1);
|
||||
|
||||
// --- Mouse look: pitch and yaw ---
|
||||
if (this.mouseMovementX !== 0 || this.mouseMovementY !== 0) {
|
||||
var pitchAngle = -this.mouseMovementY * this.mouseSensitivity;
|
||||
var yawAngle = -this.mouseMovementX * this.mouseSensitivity;
|
||||
|
||||
// Pitch around camera's local X axis
|
||||
var right = new THREE.Vector3(1, 0, 0).applyQuaternion(this.quaternion);
|
||||
var pitchQuat = new THREE.Quaternion().setFromAxisAngle(right, pitchAngle);
|
||||
this.quaternion.premultiply(pitchQuat);
|
||||
|
||||
// Yaw around world Y axis for stability
|
||||
var worldUp = new THREE.Vector3(0, 1, 0);
|
||||
var yawQuat = new THREE.Quaternion().setFromAxisAngle(worldUp, yawAngle);
|
||||
this.quaternion.premultiply(yawQuat);
|
||||
|
||||
this.quaternion.normalize();
|
||||
this.mouseMovementX = 0;
|
||||
this.mouseMovementY = 0;
|
||||
}
|
||||
|
||||
// --- Keyboard roll (A/D, Left/Right) ---
|
||||
var rollDelta = 0;
|
||||
if (this.keys['KeyA'] || this.keys['ArrowLeft']) rollDelta += this.rollSpeed * delta;
|
||||
if (this.keys['KeyD'] || this.keys['ArrowRight']) rollDelta -= this.rollSpeed * delta;
|
||||
|
||||
if (rollDelta !== 0) {
|
||||
var forward = new THREE.Vector3(0, 0, -1).applyQuaternion(this.quaternion);
|
||||
var rollQuat = new THREE.Quaternion().setFromAxisAngle(forward, rollDelta);
|
||||
this.quaternion.premultiply(rollQuat);
|
||||
this.quaternion.normalize();
|
||||
}
|
||||
|
||||
// --- Keyboard yaw / rudder (Q/E) ---
|
||||
var yawDelta = 0;
|
||||
if (this.keys['KeyQ']) yawDelta += this.yawSpeed * delta;
|
||||
if (this.keys['KeyE']) yawDelta -= this.yawSpeed * delta;
|
||||
|
||||
if (yawDelta !== 0) {
|
||||
var localUp = new THREE.Vector3(0, 1, 0).applyQuaternion(this.quaternion);
|
||||
var rudderQuat = new THREE.Quaternion().setFromAxisAngle(localUp, yawDelta);
|
||||
this.quaternion.premultiply(rudderQuat);
|
||||
this.quaternion.normalize();
|
||||
}
|
||||
|
||||
// --- Throttle (W/S, Up/Down) ---
|
||||
var boost = (this.keys['ShiftLeft'] || this.keys['ShiftRight']) ? this.boostMultiplier : 1;
|
||||
|
||||
if (this.keys['KeyW'] || this.keys['ArrowUp']) {
|
||||
this.targetSpeed = Math.min(this.targetSpeed + this.acceleration * delta, this.maxSpeed * boost);
|
||||
} else if (this.keys['KeyS'] || this.keys['ArrowDown']) {
|
||||
this.targetSpeed = Math.max(this.targetSpeed - this.deceleration * delta, this.minSpeed);
|
||||
}
|
||||
|
||||
this.targetSpeed = Math.min(this.targetSpeed, this.maxSpeed * boost);
|
||||
this.speed += (this.targetSpeed - this.speed) * Math.min(5 * delta, 1);
|
||||
|
||||
// --- Altitude (R/F) ---
|
||||
if (this.keys['KeyR']) this.verticalSpeed += this.verticalAccel * delta;
|
||||
if (this.keys['KeyF']) this.verticalSpeed -= this.verticalAccel * delta;
|
||||
this.verticalSpeed *= Math.pow(0.93, delta * 60);
|
||||
|
||||
// --- Auto-level (Space held) ---
|
||||
if (this.autoLevel) {
|
||||
var currentUp = new THREE.Vector3(0, 1, 0).applyQuaternion(this.quaternion);
|
||||
var targetUp = new THREE.Vector3(0, 1, 0);
|
||||
var correction = new THREE.Quaternion().setFromUnitVectors(currentUp, targetUp);
|
||||
var t = Math.min(this.autoLevelSpeed * delta, 1);
|
||||
correction.slerp(new THREE.Quaternion(), 1 - t);
|
||||
this.quaternion.premultiply(correction);
|
||||
this.quaternion.normalize();
|
||||
}
|
||||
|
||||
// --- Update position ---
|
||||
var fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(this.quaternion);
|
||||
this.position.addScaledVector(fwd, this.speed * delta * 60);
|
||||
this.position.y += this.verticalSpeed * delta * 60;
|
||||
|
||||
if (this.position.y < this.minAltitude) {
|
||||
this.position.y = this.minAltitude;
|
||||
this.verticalSpeed = Math.max(this.verticalSpeed, 0);
|
||||
}
|
||||
|
||||
// --- Apply to camera ---
|
||||
this.camera.position.copy(this.position);
|
||||
this.camera.quaternion.copy(this.quaternion);
|
||||
};
|
||||
|
||||
FlightControls.prototype.getHeading = function () {
|
||||
var fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(this.quaternion);
|
||||
var heading = Math.atan2(fwd.x, -fwd.z) * 180 / Math.PI;
|
||||
return ((heading % 360) + 360) % 360;
|
||||
};
|
||||
|
||||
FlightControls.prototype.getPitch = function () {
|
||||
var fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(this.quaternion);
|
||||
return Math.asin(Math.max(-1, Math.min(1, fwd.y))) * 180 / Math.PI;
|
||||
};
|
||||
|
||||
FlightControls.prototype.getRoll = function () {
|
||||
var right = new THREE.Vector3(1, 0, 0).applyQuaternion(this.quaternion);
|
||||
return Math.asin(Math.max(-1, Math.min(1, right.y))) * 180 / Math.PI;
|
||||
};
|
||||
|
||||
FlightControls.prototype.getSpeed = function () {
|
||||
return this.speed;
|
||||
};
|
||||
|
||||
FlightControls.prototype.getAltitude = function () {
|
||||
return this.position.y;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hands control back to the page. Used when a dialog opens over the
|
||||
* canvas so the aircraft is not still flying behind it.
|
||||
*/
|
||||
FlightControls.prototype.release = function () {
|
||||
if (this.isPointerLocked && document.exitPointerLock) {
|
||||
document.exitPointerLock();
|
||||
}
|
||||
this._release();
|
||||
};
|
||||
|
||||
FlightControls.prototype.resetTo = function (position, lookAt) {
|
||||
this.position.copy(position);
|
||||
this.speed = 0;
|
||||
this.targetSpeed = 0;
|
||||
this.verticalSpeed = 0;
|
||||
|
||||
var tempCam = new THREE.PerspectiveCamera();
|
||||
tempCam.position.copy(position);
|
||||
tempCam.lookAt(lookAt);
|
||||
this.quaternion.copy(tempCam.quaternion);
|
||||
|
||||
this.camera.position.copy(this.position);
|
||||
this.camera.quaternion.copy(this.quaternion);
|
||||
};
|
||||
|
||||
// Shared with the rest of the page so every document-level shortcut
|
||||
// applies the same rule about what counts as typing
|
||||
FlightControls.isTextEntryTarget = isTextEntryTarget;
|
||||
@@ -0,0 +1,314 @@
|
||||
/************************************************************************
|
||||
* Geocoder - Talks to the OpenStreetMap Nominatim API in both *
|
||||
* directions. *
|
||||
* *
|
||||
* update() reverse geocodes the camera's latitude/longitude into a *
|
||||
* human-readable location label ("City, State, Country"), falling back *
|
||||
* to "Rural <state>, <country>" when the position is not within a *
|
||||
* city/town/village boundary. Requests are throttled by time and by *
|
||||
* minimum movement so the HUD label costs as few lookups as possible. *
|
||||
* *
|
||||
* search() forward geocodes a typed state/city/country into a list of *
|
||||
* candidate destinations for the destination input dialog. *
|
||||
* *
|
||||
* Both directions go out through one queue, so the module holds to the *
|
||||
* Nominatim usage policy (absolute maximum of 1 request per second) *
|
||||
* whichever direction is asking. *
|
||||
***********************************************************************/
|
||||
var Geocoder = (function () {
|
||||
|
||||
var NOMINATIM = 'https://nominatim.openstreetmap.org/';
|
||||
|
||||
var MIN_REQUEST_INTERVAL_MS = 1000; // Nominatim policy: 1 request/second
|
||||
var MIN_INTERVAL_MS = 5000; // Minimum time between reverse lookups
|
||||
var MIN_MOVE_DEG = 0.005; // Minimum movement (~500m) before a new lookup
|
||||
var SEARCH_LIMIT = 10; // Candidates shown in the results list
|
||||
|
||||
var lastLat = null;
|
||||
var lastLng = null;
|
||||
var lastRequestTime = 0;
|
||||
var lastLabel = '';
|
||||
var pending = false;
|
||||
|
||||
// Bumped by reset(). A reverse lookup carries the value it was queued
|
||||
// under, so a response that belongs to the location left behind is
|
||||
// recognized as stale instead of overwriting the HUD with it.
|
||||
var generation = 0;
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
Shared request queue
|
||||
|
||||
Nominatim counts requests per client across its endpoints, so
|
||||
reverse lookups and searches cannot each keep their own pace.
|
||||
One request is in flight at a time and consecutive sends are
|
||||
spaced by MIN_REQUEST_INTERVAL_MS.
|
||||
------------------------------------------------------------ */
|
||||
|
||||
var queue = [];
|
||||
var inFlight = null;
|
||||
var lastSendTime = 0;
|
||||
var drainTimer = null;
|
||||
|
||||
/**
|
||||
* Queues one request. job is
|
||||
* { url, success(data), failure(reason), stale() }, where reason is
|
||||
* { status, malformed } and the optional stale() predicate lets a
|
||||
* job whose result is no longer wanted be dropped before it is sent.
|
||||
*/
|
||||
function enqueue(job) {
|
||||
queue.push(job);
|
||||
drain();
|
||||
}
|
||||
|
||||
function drain() {
|
||||
if (inFlight) return;
|
||||
|
||||
// Drop abandoned jobs before they spend a request slot
|
||||
while (queue.length && queue[0].stale && queue[0].stale()) {
|
||||
queue.shift();
|
||||
}
|
||||
if (!queue.length) return;
|
||||
|
||||
var wait = MIN_REQUEST_INTERVAL_MS - (performance.now() - lastSendTime);
|
||||
if (wait > 0) {
|
||||
if (drainTimer === null) {
|
||||
drainTimer = setTimeout(function () {
|
||||
drainTimer = null;
|
||||
drain();
|
||||
}, wait);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
send(queue.shift());
|
||||
}
|
||||
|
||||
function send(job) {
|
||||
var settled = false;
|
||||
var xhr = new XMLHttpRequest();
|
||||
|
||||
inFlight = xhr;
|
||||
lastSendTime = performance.now();
|
||||
|
||||
// A network failure raises both onerror and a readyState 4 with no
|
||||
// status, so the first outcome to arrive is the one that counts
|
||||
function finish(handler, argument) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
inFlight = null;
|
||||
handler(argument);
|
||||
drain();
|
||||
}
|
||||
|
||||
xhr.open('GET', job.url, true);
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState !== 4) return;
|
||||
|
||||
if (xhr.status !== 200) {
|
||||
finish(job.failure, { status: xhr.status, malformed: false });
|
||||
return;
|
||||
}
|
||||
|
||||
var data;
|
||||
try {
|
||||
data = JSON.parse(xhr.responseText);
|
||||
} catch (e) {
|
||||
finish(job.failure, { status: xhr.status, malformed: true });
|
||||
return;
|
||||
}
|
||||
|
||||
finish(job.success, data);
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
finish(job.failure, { status: 0, malformed: false });
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a display label from a Nominatim address object.
|
||||
* Within a city boundary: "City, State, Country".
|
||||
* Outside one: "Rural State, Country" (e.g. "Rural New York, United States").
|
||||
*/
|
||||
function buildLabel(address) {
|
||||
// Only city/town count as being "within a city"; villages, hamlets,
|
||||
// and US civil townships ("Town of X" -> village) read as rural
|
||||
var city = address.city || address.town;
|
||||
var state = address.state || address.province || address.region ||
|
||||
address.county;
|
||||
var country = address.country;
|
||||
var parts = [];
|
||||
|
||||
if (city) {
|
||||
parts.push(city);
|
||||
if (state) parts.push(state);
|
||||
if (country) parts.push(country);
|
||||
} else {
|
||||
var area = state || country;
|
||||
if (!area) return '';
|
||||
parts.push('Rural ' + area);
|
||||
if (state && country) parts.push(country);
|
||||
}
|
||||
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a label for the given position. Throttled internally,
|
||||
* so it is safe to call every frame. Invokes callback(label) only
|
||||
* when the label has changed.
|
||||
*/
|
||||
function update(lat, lng, callback) {
|
||||
var now = performance.now();
|
||||
if (pending || now - lastRequestTime < MIN_INTERVAL_MS) return;
|
||||
if (lastLat !== null &&
|
||||
Math.abs(lat - lastLat) < MIN_MOVE_DEG &&
|
||||
Math.abs(lng - lastLng) < MIN_MOVE_DEG) return;
|
||||
|
||||
pending = true;
|
||||
lastRequestTime = now;
|
||||
|
||||
var token = generation;
|
||||
function abandoned() {
|
||||
return token !== generation;
|
||||
}
|
||||
|
||||
enqueue({
|
||||
url: NOMINATIM + 'reverse?format=jsonv2&zoom=10' +
|
||||
'&lat=' + encodeURIComponent(lat) +
|
||||
'&lon=' + encodeURIComponent(lng),
|
||||
stale: abandoned,
|
||||
success: function (data) {
|
||||
// Switched location while this was in flight: the label and
|
||||
// the position it describes belong to somewhere else now
|
||||
if (abandoned()) return;
|
||||
pending = false;
|
||||
|
||||
var label = buildLabel((data && data.address) || {});
|
||||
lastLat = lat;
|
||||
lastLng = lng;
|
||||
if (label && label !== lastLabel) {
|
||||
lastLabel = label;
|
||||
callback(label);
|
||||
}
|
||||
},
|
||||
failure: function () {
|
||||
if (abandoned()) return;
|
||||
pending = false;
|
||||
// Keep the last label on failure or a malformed response
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears cached state when switching capitals so the next lookup
|
||||
* runs immediately for the new location. Lookups already queued or
|
||||
* in flight for the old location are abandoned rather than allowed
|
||||
* to report back over the new one.
|
||||
*/
|
||||
function reset() {
|
||||
generation++;
|
||||
lastLat = null;
|
||||
lastLng = null;
|
||||
lastRequestTime = 0;
|
||||
lastLabel = '';
|
||||
pending = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the label shown for one search result:
|
||||
* "State, City, CC" (e.g. "New York, New York City, US").
|
||||
*
|
||||
* Nominatim omits whichever administrative levels a place does not
|
||||
* have, so the parts that exist are joined and the raw display_name
|
||||
* covers a result that carries no structured address at all.
|
||||
*/
|
||||
function buildResultLabel(item) {
|
||||
var address = item.address || {};
|
||||
var city = address.city || address.town || address.village ||
|
||||
address.municipality || address.hamlet;
|
||||
var state = address.state || address.province || address.region ||
|
||||
address.county;
|
||||
var code = address.country_code ? address.country_code.toUpperCase() : '';
|
||||
var parts = [];
|
||||
|
||||
if (state) parts.push(state);
|
||||
if (city && city !== state) parts.push(city);
|
||||
if (code) parts.push(code);
|
||||
|
||||
if (!parts.length) return item.display_name || 'Unknown location';
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward geocodes a destination typed into the input dialog.
|
||||
*
|
||||
* Fields are optional individually but at least one must be filled;
|
||||
* they are sent as Nominatim's structured query parameters rather
|
||||
* than concatenated into a free-text string, which keeps a city name
|
||||
* that also exists in another country from outranking the match.
|
||||
*
|
||||
* Calls callback(error, results), where each result is
|
||||
* { label, lat, lng, city, country } ready for loadCapital().
|
||||
*/
|
||||
function search(fields, callback) {
|
||||
var params = [];
|
||||
var keys = ['state', 'city', 'country'];
|
||||
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var value = (fields[keys[i]] || '').trim();
|
||||
if (value) params.push(keys[i] + '=' + encodeURIComponent(value));
|
||||
}
|
||||
|
||||
if (!params.length) {
|
||||
callback('Enter a state/region, a city, or a country to search.', []);
|
||||
return;
|
||||
}
|
||||
|
||||
enqueue({
|
||||
url: NOMINATIM + 'search?format=jsonv2&addressdetails=1' +
|
||||
'&limit=' + SEARCH_LIMIT + '&' + params.join('&'),
|
||||
success: function (data) {
|
||||
if (!data || !data.length) {
|
||||
callback(null, []);
|
||||
return;
|
||||
}
|
||||
|
||||
var results = [];
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
var item = data[i];
|
||||
var lat = parseFloat(item.lat);
|
||||
var lng = parseFloat(item.lon);
|
||||
if (isNaN(lat) || isNaN(lng)) continue;
|
||||
|
||||
var address = item.address || {};
|
||||
results.push({
|
||||
label: buildResultLabel(item),
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
city: address.city || address.town || address.village ||
|
||||
item.name || 'Destination',
|
||||
country: address.country || ''
|
||||
});
|
||||
}
|
||||
|
||||
callback(null, results);
|
||||
},
|
||||
failure: function (reason) {
|
||||
if (reason.malformed) {
|
||||
callback('Search returned a malformed response.', []);
|
||||
} else if (!reason.status) {
|
||||
callback('Search could not reach the geocoding service.', []);
|
||||
} else {
|
||||
callback('Search failed (' + reason.status + ').', []);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
update: update,
|
||||
reset: reset,
|
||||
search: search
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,73 @@
|
||||
/************************************************************************
|
||||
* HostBridge - Thin abstraction over the VS Code webview messaging API. *
|
||||
* *
|
||||
* The simulator runs in two places: inside a VS Code webview panel and *
|
||||
* as a plain page in a browser. Everything that needs the extension *
|
||||
* host (config injection, saving renderMap.json) goes through here so *
|
||||
* the rest of the code stays identical in both environments. *
|
||||
* *
|
||||
* In the browser the API is absent and every call degrades to a no-op, *
|
||||
* leaving the original web behavior in place. *
|
||||
***********************************************************************/
|
||||
var HostBridge = (function () {
|
||||
|
||||
var api = null;
|
||||
var handlers = {};
|
||||
|
||||
// acquireVsCodeApi exists only inside a webview, and may be called
|
||||
// exactly once per page load
|
||||
if (typeof acquireVsCodeApi === 'function') {
|
||||
try {
|
||||
api = acquireVsCodeApi();
|
||||
} catch (e) {
|
||||
api = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (api) {
|
||||
window.addEventListener('message', function (event) {
|
||||
var message = event.data;
|
||||
if (!message || !message.command) return;
|
||||
var fn = handlers[message.command];
|
||||
if (fn) fn(message);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* True when running inside the VS Code webview panel.
|
||||
*/
|
||||
function isHosted() {
|
||||
return api !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the extension host. No-op in the browser.
|
||||
*/
|
||||
function post(message) {
|
||||
if (api) api.postMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for a command sent by the extension host.
|
||||
*/
|
||||
function on(command, fn) {
|
||||
handlers[command] = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render configuration injected by the extension host, or null when
|
||||
* running as a standalone page.
|
||||
*/
|
||||
function getInjectedConfig() {
|
||||
return (window.__flightMapConfig && typeof window.__flightMapConfig === 'object')
|
||||
? window.__flightMapConfig
|
||||
: null;
|
||||
}
|
||||
|
||||
return {
|
||||
isHosted: isHosted,
|
||||
post: post,
|
||||
on: on,
|
||||
getInjectedConfig: getInjectedConfig
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,284 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<!--{{HOST_HEAD}}-->
|
||||
<title>Flight Map</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Top Bar: capital selector on the left, location readout centered.
|
||||
The bar owns both so the readout can never sit under the menu. -->
|
||||
<div id="top-bar">
|
||||
<div id="selector-panel">
|
||||
<label for="capital-select">Destination</label>
|
||||
<!-- The placeholder is hidden from the list so "Input Destination"
|
||||
reads as the first entry, but still shows as the closed value
|
||||
until a destination has been chosen. -->
|
||||
<select id="capital-select">
|
||||
<option value="" selected hidden>-- Select a Capital --</option>
|
||||
<option value="__input__" id="opt-input-destination">Input Destination</option>
|
||||
</select>
|
||||
<button id="btn-toggle-config" type="button" title="Configure Rendering">Config</button>
|
||||
<button id="btn-export-json" type="button" title="Export renderMap.json">Export JSON</button>
|
||||
</div>
|
||||
|
||||
<div id="hud-location" class="hidden">
|
||||
<div id="hud-capital">---</div>
|
||||
<div id="hud-coords">---</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Render Config Panel -->
|
||||
<div id="config-panel" class="hidden">
|
||||
<h3>Render Configuration</h3>
|
||||
<p class="config-hint">Adjust rendering parameters to determine suitability for different gaming applications.</p>
|
||||
|
||||
<fieldset>
|
||||
<legend>Map</legend>
|
||||
<div class="cfg-row">
|
||||
<label for="cfg-width">Width</label>
|
||||
<input type="range" id="cfg-width" data-config="map.width" min="640" max="3840" step="10">
|
||||
<span id="cfg-width-val"></span>
|
||||
</div>
|
||||
<div class="cfg-row">
|
||||
<label for="cfg-height">Height</label>
|
||||
<input type="range" id="cfg-height" data-config="map.height" min="480" max="2160" step="10">
|
||||
<span id="cfg-height-val"></span>
|
||||
</div>
|
||||
<div class="cfg-row">
|
||||
<label for="cfg-tileZoom">Tile Zoom</label>
|
||||
<input type="range" id="cfg-tileZoom" data-config="map.tileZoom" min="10" max="19" step="1">
|
||||
<span id="cfg-tileZoom-val"></span>
|
||||
</div>
|
||||
<div class="cfg-row">
|
||||
<label for="cfg-loadRadius">Load Radius</label>
|
||||
<input type="range" id="cfg-loadRadius" data-config="map.loadRadius" min="2" max="48" step="1">
|
||||
<span id="cfg-loadRadius-val"></span>
|
||||
</div>
|
||||
<div class="cfg-row">
|
||||
<label for="cfg-radiusFeather">Radius Feather</label>
|
||||
<input type="range" id="cfg-radiusFeather" data-config="map.radiusFeather" min="0" max="100" step="0.5">
|
||||
<span id="cfg-radiusFeather-val"></span>
|
||||
</div>
|
||||
<div class="cfg-row">
|
||||
<label for="cfg-startAlt">Start Altitude</label>
|
||||
<input type="range" id="cfg-startAlt" data-config="map.startAltitude" min="50" max="1000" step="10">
|
||||
<span id="cfg-startAlt-val"></span>
|
||||
</div>
|
||||
<div class="cfg-row">
|
||||
<label for="cfg-fogDensity">Fog Density</label>
|
||||
<input type="range" id="cfg-fogDensity" data-config="map.fogDensity" min="0.00001" max="0.01" step="0.00001">
|
||||
<span id="cfg-fogDensity-val"></span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Filler</legend>
|
||||
<div class="cfg-row">
|
||||
<label for="cfg-sampleInterval">Sample Interval</label>
|
||||
<input type="range" id="cfg-sampleInterval" data-config="filler.sampleInterval" min="10" max="120" step="5">
|
||||
<span id="cfg-sampleInterval-val"></span>
|
||||
</div>
|
||||
<div class="cfg-row">
|
||||
<label for="cfg-updateInterval">Update Time (s)</label>
|
||||
<input type="range" id="cfg-updateInterval" data-config="filler.updateIntervalSec" min="5" max="60" step="1">
|
||||
<span id="cfg-updateInterval-val"></span>
|
||||
</div>
|
||||
<div class="cfg-row">
|
||||
<label for="cfg-perimCount">Perimeter Count</label>
|
||||
<input type="range" id="cfg-perimCount" data-config="filler.perimeter.patchCount" min="1" max="20" step="1">
|
||||
<span id="cfg-perimCount-val"></span>
|
||||
</div>
|
||||
<div class="cfg-row">
|
||||
<label for="cfg-perimSize">Perimeter Size</label>
|
||||
<input type="range" id="cfg-perimSize" data-config="filler.perimeter.patchSize" min="2" max="20" step="1">
|
||||
<span id="cfg-perimSize-val"></span>
|
||||
</div>
|
||||
<div class="cfg-row">
|
||||
<label for="cfg-centerCount">Center Count</label>
|
||||
<input type="range" id="cfg-centerCount" data-config="filler.center.patchCount" min="1" max="15" step="1">
|
||||
<span id="cfg-centerCount-val"></span>
|
||||
</div>
|
||||
<div class="cfg-row">
|
||||
<label for="cfg-centerSize">Center Size</label>
|
||||
<input type="range" id="cfg-centerSize" data-config="filler.center.patchSize" min="2" max="20" step="1">
|
||||
<span id="cfg-centerSize-val"></span>
|
||||
</div>
|
||||
<div class="cfg-row">
|
||||
<label for="cfg-padCount">Padding Count</label>
|
||||
<input type="range" id="cfg-padCount" data-config="filler.padding.patchCount" min="1" max="12" step="1">
|
||||
<span id="cfg-padCount-val"></span>
|
||||
</div>
|
||||
<div class="cfg-row">
|
||||
<label for="cfg-padSize">Padding Size</label>
|
||||
<input type="range" id="cfg-padSize" data-config="filler.padding.patchSize" min="2" max="15" step="1">
|
||||
<span id="cfg-padSize-val"></span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Cloud Effect</legend>
|
||||
<div class="cfg-row">
|
||||
<label for="cfg-cloud-enabled">Enabled</label>
|
||||
<input type="checkbox" id="cfg-cloud-enabled">
|
||||
</div>
|
||||
<div class="cfg-row">
|
||||
<label for="cfg-cloudOpacity">Opacity</label>
|
||||
<input type="range" id="cfg-cloudOpacity" data-config="cloud.opacity" min="0" max="1" step="0.05">
|
||||
<span id="cfg-cloudOpacity-val"></span>
|
||||
</div>
|
||||
<div class="cfg-row">
|
||||
<label for="cfg-cloudSpeed">Speed</label>
|
||||
<input type="range" id="cfg-cloudSpeed" data-config="cloud.speed" min="0.0001" max="0.002" step="0.0001">
|
||||
<span id="cfg-cloudSpeed-val"></span>
|
||||
</div>
|
||||
<div class="cfg-row">
|
||||
<label for="cfg-cloudCoverage">Coverage</label>
|
||||
<input type="range" id="cfg-cloudCoverage" data-config="cloud.coverage" min="0.1" max="1" step="0.05">
|
||||
<span id="cfg-cloudCoverage-val"></span>
|
||||
</div>
|
||||
<div class="cfg-row">
|
||||
<label for="cfg-cloudScale">Scale</label>
|
||||
<input type="range" id="cfg-cloudScale" data-config="cloud.scale" min="0.002" max="0.03" step="0.001">
|
||||
<span id="cfg-cloudScale-val"></span>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<!-- Destination Input Dialog: opened by the "Input Destination" entry
|
||||
in the destination menu. Stage one collects a typed location,
|
||||
stage two lists the matches the geocoder returned for it. -->
|
||||
<div id="destination-dialog" class="hidden" role="dialog" aria-modal="true"
|
||||
aria-label="Input destination">
|
||||
<div class="destination-card">
|
||||
|
||||
<div id="destination-form">
|
||||
<div class="dest-row">
|
||||
<label class="dest-label" for="dest-state">State/region</label>
|
||||
<input type="text" id="dest-state" placeholder="Keyboard Input"
|
||||
autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="dest-row">
|
||||
<label class="dest-label" for="dest-city">City</label>
|
||||
<input type="text" id="dest-city" placeholder="Keyboard Input"
|
||||
autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="dest-row">
|
||||
<label class="dest-label" for="dest-country">Country</label>
|
||||
<select id="dest-country">
|
||||
<option value="">Dropdown Menu</option>
|
||||
</select>
|
||||
</div>
|
||||
<p id="dest-form-message" class="dest-message" role="status"></p>
|
||||
<div class="dest-actions">
|
||||
<button id="dest-search" type="button">Search</button>
|
||||
<button id="dest-cancel" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="destination-results" class="hidden">
|
||||
<ul id="dest-result-list" role="listbox" tabindex="0"
|
||||
aria-label="Matching destinations"></ul>
|
||||
<p id="dest-results-message" class="dest-message" role="status"></p>
|
||||
<div class="dest-actions">
|
||||
<button id="dest-select" type="button">Select</button>
|
||||
<button id="dest-results-cancel" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3D Canvas Container -->
|
||||
<div id="canvas-container"></div>
|
||||
|
||||
<!-- HUD Overlay -->
|
||||
<div id="hud" class="hidden">
|
||||
<div id="hud-heading">
|
||||
<span id="hud-heading-value">N 000</span>
|
||||
</div>
|
||||
<div id="hud-speed">
|
||||
<div class="hud-label">SPD</div>
|
||||
<div id="hud-speed-value">0</div>
|
||||
<div class="hud-unit">km/h</div>
|
||||
</div>
|
||||
<div id="hud-altitude">
|
||||
<div class="hud-label">ALT</div>
|
||||
<div id="hud-altitude-value">0</div>
|
||||
<div class="hud-unit">m</div>
|
||||
</div>
|
||||
<div id="hud-crosshair">+</div>
|
||||
<div id="hud-pitch">
|
||||
<span id="hud-pitch-value">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Click to Fly Prompt -->
|
||||
<div id="fly-prompt" class="hidden">
|
||||
<div class="prompt-text">Click to Fly</div>
|
||||
<div class="prompt-hint" id="fly-prompt-hint"></div>
|
||||
</div>
|
||||
|
||||
<!-- Controls Help -->
|
||||
<div id="controls-help" class="hidden">
|
||||
<div>W/S: Speed · <span id="controls-help-look">Mouse: Look</span> · A/D: Roll</div>
|
||||
<div>Q/E: Yaw · R/F: Alt · Space: Level · Shift: Boost · P: Pause</div>
|
||||
</div>
|
||||
|
||||
<!-- Static Controls List -->
|
||||
<div id="controls-list" class="hidden">
|
||||
<div class="controls-title">Controls</div>
|
||||
<ul>
|
||||
<li><span>W / S</span>Throttle</li>
|
||||
<li><span id="controls-list-look">Mouse</span>Look</li>
|
||||
<li><span>A / D</span>Roll</li>
|
||||
<li><span>Q / E</span>Yaw</li>
|
||||
<li><span>R / F</span>Climb / Descend</li>
|
||||
<li><span>Space</span>Auto-level</li>
|
||||
<li><span>Shift</span>Boost</li>
|
||||
<li><span>P</span>Pause</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Agent Status: what the host reports its agent is working on. Stays
|
||||
hidden until a host sends a status, so the VS Code panel and the
|
||||
standalone page never show it. -->
|
||||
<div id="agent-status" class="hidden">
|
||||
<span id="agent-status-dot"></span>
|
||||
<span id="agent-status-text"></span>
|
||||
<span id="agent-status-tokens"></span>
|
||||
</div>
|
||||
|
||||
<!-- Pause Indicator -->
|
||||
<div id="pause-indicator" class="hidden">Paused</div>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div id="loading-overlay" class="hidden">
|
||||
<div id="loading-text">Loading terrain...</div>
|
||||
<div id="loading-bar-container">
|
||||
<div id="loading-bar"></div>
|
||||
</div>
|
||||
<div id="loading-progress">0%</div>
|
||||
</div>
|
||||
|
||||
<!-- Welcome Screen -->
|
||||
<div id="welcome">
|
||||
<h1>3D Flight Simulator</h1>
|
||||
<p>Satellite Terrain Viewer - World Capitals</p>
|
||||
<p class="welcome-hint">Select a capital city to begin</p>
|
||||
</div>
|
||||
|
||||
<script src="vendor/three.min.js"></script>
|
||||
<script src="hostBridge.js"></script>
|
||||
<script src="capitals.js"></script>
|
||||
<script src="geocoder.js"></script>
|
||||
<script src="destinationInput.js"></script>
|
||||
<script src="flightControls.js"></script>
|
||||
<script src="terrainFill.js"></script>
|
||||
<script src="renderConfig.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,269 @@
|
||||
/***************************************************************************
|
||||
* RenderConfig - Loads, applies, and exports map rendering configuration. *
|
||||
* *
|
||||
* Inside the VS Code panel the extension host reads renderMap.json and *
|
||||
* injects it before the page scripts run. As a standalone page the *
|
||||
* config is fetched from renderMap.json in the project root. Either way *
|
||||
* the values are merged over the defaults and applied to the live *
|
||||
* simulation. *
|
||||
* *
|
||||
* Export hands the current configuration to the extension host, which *
|
||||
* writes it through a save dialog. In the browser it falls back to a *
|
||||
* plain file download. *
|
||||
**************************************************************************/
|
||||
var RenderConfig = (function () {
|
||||
|
||||
var defaults = {
|
||||
map: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
tileZoom: 15,
|
||||
tileSize: 100,
|
||||
loadRadius: 6,
|
||||
unloadRadius: 9,
|
||||
startAltitude: 200,
|
||||
fogDensity: 0.00018,
|
||||
radiusFeather: 3,
|
||||
maxTiles: 600
|
||||
},
|
||||
filler: {
|
||||
sampleInterval: 45,
|
||||
updateIntervalSec: 10,
|
||||
perimeter: { patchCount: 10, patchSize: 10 },
|
||||
center: { patchCount: 5, patchSize: 10 },
|
||||
padding: { patchCount: 4, patchSize: 5 }
|
||||
},
|
||||
cloud: {
|
||||
enabled: true,
|
||||
opacity: 0.35,
|
||||
speed: 0.0004,
|
||||
coverage: 0.5,
|
||||
scale: 0.008
|
||||
}
|
||||
};
|
||||
|
||||
var config = JSON.parse(JSON.stringify(defaults));
|
||||
var listeners = [];
|
||||
|
||||
/**
|
||||
* Deep merge source into target, preserving existing keys.
|
||||
*/
|
||||
function merge(target, source) {
|
||||
for (var key in source) {
|
||||
if (!source.hasOwnProperty(key)) continue;
|
||||
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||
if (!target[key]) target[key] = {};
|
||||
merge(target[key], source[key]);
|
||||
} else {
|
||||
target[key] = source[key];
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function freshDefaults() {
|
||||
return JSON.parse(JSON.stringify(defaults));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration, merged over defaults. Prefers the config the
|
||||
* extension host injected; otherwise reads renderMap.json from the
|
||||
* project root relative to this page.
|
||||
*/
|
||||
function load(callback) {
|
||||
var injected = HostBridge.getInjectedConfig();
|
||||
if (injected) {
|
||||
config = merge(freshDefaults(), injected);
|
||||
if (callback) callback(config);
|
||||
return;
|
||||
}
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '../renderMap.json', true);
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState !== 4) return;
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
var data = JSON.parse(xhr.responseText);
|
||||
config = merge(freshDefaults(), data);
|
||||
} catch (e) {
|
||||
console.warn('RenderConfig: invalid JSON, using defaults');
|
||||
config = freshDefaults();
|
||||
}
|
||||
} else {
|
||||
console.warn('RenderConfig: could not load renderMap.json, using defaults');
|
||||
config = freshDefaults();
|
||||
}
|
||||
if (callback) callback(config);
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full config object.
|
||||
*/
|
||||
function get() {
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a config value by dot-notation path (e.g. "cloud.opacity").
|
||||
*/
|
||||
function set(path, value) {
|
||||
var keys = path.split('.');
|
||||
var obj = config;
|
||||
for (var i = 0; i < keys.length - 1; i++) {
|
||||
if (!obj[keys[i]]) obj[keys[i]] = {};
|
||||
obj = obj[keys[i]];
|
||||
}
|
||||
obj[keys[keys.length - 1]] = value;
|
||||
apply();
|
||||
notify(path, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to config changes.
|
||||
*/
|
||||
function onChange(fn) {
|
||||
listeners.push(fn);
|
||||
}
|
||||
|
||||
function notify(path, value) {
|
||||
for (var i = 0; i < listeners.length; i++) {
|
||||
listeners[i](path, value, config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply current config values to the live simulation globals.
|
||||
*/
|
||||
function apply() {
|
||||
// Map globals
|
||||
TILE_ZOOM = config.map.tileZoom;
|
||||
TILE_SIZE = config.map.tileSize;
|
||||
LOAD_RADIUS = config.map.loadRadius;
|
||||
UNLOAD_RADIUS = Math.max(config.map.unloadRadius, LOAD_RADIUS + 3);
|
||||
START_ALTITUDE = config.map.startAltitude;
|
||||
FOG_DENSITY = config.map.fogDensity;
|
||||
RADIUS_FEATHER = config.map.radiusFeather;
|
||||
MAX_TILES = config.map.maxTiles;
|
||||
|
||||
// Apply fog density to scene
|
||||
if (typeof scene !== 'undefined' && scene && scene.fog) {
|
||||
scene.fog.density = config.map.fogDensity;
|
||||
}
|
||||
|
||||
// Apply render resolution. CSS stretches the canvas buffer over
|
||||
// the panel, so a buffer shaped differently from the panel would
|
||||
// render distorted. Keep the configured width x height as the
|
||||
// pixel budget and reshape it to the panel's aspect ratio, so
|
||||
// resizing the panel changes how much is in frame rather than
|
||||
// how the frame is squashed.
|
||||
if (typeof renderer !== 'undefined' && renderer &&
|
||||
typeof camera !== 'undefined' && camera &&
|
||||
config.map.width > 0 && config.map.height > 0) {
|
||||
|
||||
var aspect = (window.innerWidth > 0 && window.innerHeight > 0)
|
||||
? window.innerWidth / window.innerHeight
|
||||
: config.map.width / config.map.height;
|
||||
|
||||
var pixelBudget = config.map.width * config.map.height;
|
||||
var height = Math.max(1, Math.round(Math.sqrt(pixelBudget / aspect)));
|
||||
var width = Math.max(1, Math.round(height * aspect));
|
||||
|
||||
renderer.setSize(width, height, false);
|
||||
camera.aspect = aspect;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
// Apply filler settings to TerrainFill instance
|
||||
if (typeof terrainFill !== 'undefined' && terrainFill) {
|
||||
terrainFill.sampleInterval = config.filler.sampleInterval;
|
||||
terrainFill.updateIntervalSec = config.filler.updateIntervalSec;
|
||||
terrainFill.perimeterPatchCount = config.filler.perimeter.patchCount;
|
||||
terrainFill.perimeterPatchSize = config.filler.perimeter.patchSize;
|
||||
terrainFill.centerPatchCount = config.filler.center.patchCount;
|
||||
terrainFill.centerPatchSize = config.filler.center.patchSize;
|
||||
terrainFill.paddingPatchCount = config.filler.padding.patchCount;
|
||||
terrainFill.paddingPatchSize = config.filler.padding.patchSize;
|
||||
}
|
||||
|
||||
// Apply cloud settings
|
||||
if (typeof terrainFill !== 'undefined' && terrainFill && terrainFill.cloudOverlay) {
|
||||
terrainFill.cloudOverlay.enabled = config.cloud.enabled;
|
||||
terrainFill.cloudOverlay.opacity = config.cloud.opacity;
|
||||
terrainFill.cloudOverlay.speed = config.cloud.speed;
|
||||
terrainFill.cloudOverlay.coverage = config.cloud.coverage;
|
||||
terrainFill.cloudOverlay.scale = config.cloud.scale;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the current configuration as renderMap.json. The extension
|
||||
* host owns the file write so the save lands in the workspace; a
|
||||
* standalone page falls back to a browser download.
|
||||
*/
|
||||
function exportJSON() {
|
||||
var json = JSON.stringify(config, null, 4);
|
||||
|
||||
if (HostBridge.isHosted()) {
|
||||
HostBridge.post({ command: 'exportConfig', json: json });
|
||||
return;
|
||||
}
|
||||
|
||||
var blob = new Blob([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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate slider/input elements from the current config and bind change events.
|
||||
*/
|
||||
function bindSliders() {
|
||||
var sliders = document.querySelectorAll('[data-config]');
|
||||
for (var i = 0; i < sliders.length; i++) {
|
||||
(function (el) {
|
||||
var path = el.getAttribute('data-config');
|
||||
var keys = path.split('.');
|
||||
var val = config;
|
||||
for (var k = 0; k < keys.length; k++) {
|
||||
val = val[keys[k]];
|
||||
}
|
||||
el.value = val;
|
||||
var display = document.getElementById(el.id + '-val');
|
||||
if (display) display.textContent = val;
|
||||
|
||||
el.addEventListener('input', function () {
|
||||
var v = parseFloat(el.value);
|
||||
set(path, v);
|
||||
if (display) display.textContent = v;
|
||||
});
|
||||
})(sliders[i]);
|
||||
}
|
||||
|
||||
// Cloud enabled checkbox
|
||||
var cloudToggle = document.getElementById('cfg-cloud-enabled');
|
||||
if (cloudToggle) {
|
||||
cloudToggle.checked = config.cloud.enabled;
|
||||
cloudToggle.addEventListener('change', function () {
|
||||
set('cloud.enabled', cloudToggle.checked);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
load: load,
|
||||
get: get,
|
||||
set: set,
|
||||
apply: apply,
|
||||
exportJSON: exportJSON,
|
||||
bindSliders: bindSliders,
|
||||
onChange: onChange
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,846 @@
|
||||
/* ============================================================
|
||||
3D Flight Simulator - Styles
|
||||
============================================================ */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
background: #000;
|
||||
color: #e0e0e0;
|
||||
overflow: hidden;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* ---- Canvas Container ---- */
|
||||
#canvas-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#canvas-container canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ---- Top Bar ---- */
|
||||
|
||||
/*
|
||||
The selector panel and the location readout share one row. The bar
|
||||
mirrors the selector panel's measured width as right padding so the
|
||||
readout reads as centered in the panel rather than in the space left
|
||||
over beside the menu. When the panel is too narrow for both, app.js
|
||||
adds .stacked and the readout takes its own row underneath.
|
||||
|
||||
--selector-width and --top-bar-height are published by app.js.
|
||||
*/
|
||||
#top-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: auto;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
padding: 12px;
|
||||
padding-right: calc(12px + var(--selector-width, 0px));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#top-bar.stacked {
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
/* ---- Capital Selector ---- */
|
||||
#selector-panel {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(100, 200, 255, 0.2);
|
||||
backdrop-filter: blur(8px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#selector-panel label {
|
||||
font-size: 0.75rem;
|
||||
color: #88bbdd;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
#capital-select {
|
||||
padding: 5px 10px;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
border: 1px solid rgba(100, 200, 255, 0.3);
|
||||
border-radius: 3px;
|
||||
background: rgba(10, 20, 40, 0.9);
|
||||
color: #c0e0f0;
|
||||
cursor: pointer;
|
||||
flex: 1 1 auto;
|
||||
min-width: 140px;
|
||||
max-width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#capital-select:focus {
|
||||
border-color: rgba(100, 200, 255, 0.6);
|
||||
}
|
||||
|
||||
#capital-select optgroup {
|
||||
color: #66aacc;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#capital-select option {
|
||||
color: #c0e0f0;
|
||||
background: #0a1428;
|
||||
}
|
||||
|
||||
/* ---- HUD Overlay ---- */
|
||||
#hud {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Location display (lives in #top-bar; the flex basis below is mirrored
|
||||
by LOCATION_MIN_WIDTH in app.js, which decides when to stack) */
|
||||
#hud-location {
|
||||
/* flex: 1 1 240px; */
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Both lines sit over bright satellite imagery, so they carry a hard
|
||||
1px drop shadow rather than a blurred glow. A blur radius spreads the
|
||||
dark halo through the glyphs themselves and softens the strokes, which
|
||||
is what made the coordinates unreadable against a pale sky. */
|
||||
#hud-capital {
|
||||
font-size: calc(1rem + 2pt);
|
||||
color: rgb(235, 246, 255);
|
||||
font-weight: bold;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.85);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* white-space: pre keeps the two spaces that separate the latitude from
|
||||
the longitude; normal whitespace handling would collapse them to one */
|
||||
#hud-coords {
|
||||
font-size: calc(0.72rem + 2pt);
|
||||
color: rgb(214, 234, 250);
|
||||
margin-top: 2px;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.85);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* Heading: sits under the top bar at whatever height it settled on */
|
||||
#hud-heading {
|
||||
position: absolute;
|
||||
top: calc(var(--top-bar-height, 56px) + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
padding: 3px 16px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(100, 200, 255, 0.15);
|
||||
}
|
||||
|
||||
#hud-heading-value {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(100, 220, 255, 0.85);
|
||||
text-shadow: 0 0 6px rgba(0, 100, 200, 0.3);
|
||||
}
|
||||
|
||||
/* Speed (left side) */
|
||||
#hud-speed {
|
||||
position: absolute;
|
||||
left: 30px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
text-align: center;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
padding: 10px 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(100, 200, 255, 0.15);
|
||||
}
|
||||
|
||||
#hud-speed .hud-label {
|
||||
font-size: 0.65rem;
|
||||
color: rgba(100, 200, 255, 0.5);
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
#hud-speed-value {
|
||||
font-size: 1.6rem;
|
||||
color: rgba(100, 255, 180, 0.9);
|
||||
font-weight: bold;
|
||||
text-shadow: 0 0 10px rgba(0, 200, 100, 0.3);
|
||||
}
|
||||
|
||||
#hud-speed .hud-unit {
|
||||
font-size: 0.6rem;
|
||||
color: rgba(100, 200, 255, 0.4);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Altitude (right side) */
|
||||
#hud-altitude {
|
||||
position: absolute;
|
||||
right: 30px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
text-align: center;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
padding: 10px 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(100, 200, 255, 0.15);
|
||||
}
|
||||
|
||||
#hud-altitude .hud-label {
|
||||
font-size: 0.65rem;
|
||||
color: rgba(100, 200, 255, 0.5);
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
#hud-altitude-value {
|
||||
font-size: 1.6rem;
|
||||
color: rgba(255, 200, 100, 0.9);
|
||||
font-weight: bold;
|
||||
text-shadow: 0 0 10px rgba(200, 150, 0, 0.3);
|
||||
}
|
||||
|
||||
#hud-altitude .hud-unit {
|
||||
font-size: 0.6rem;
|
||||
color: rgba(100, 200, 255, 0.4);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Crosshair */
|
||||
#hud-crosshair {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 1.5rem;
|
||||
color: rgba(200, 230, 255, 0.4);
|
||||
text-shadow: 0 0 4px rgba(0, 0, 0, 0.5);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Pitch indicator */
|
||||
#hud-pitch {
|
||||
position: absolute;
|
||||
right: 30px;
|
||||
top: 50%;
|
||||
transform: translateY(80px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#hud-pitch-value {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(180, 200, 255, 0.6);
|
||||
}
|
||||
|
||||
/* ---- Click to Fly Prompt ---- */
|
||||
#fly-prompt {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 80;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#fly-prompt .prompt-text {
|
||||
font-size: 1.8rem;
|
||||
color: rgba(180, 220, 255, 0.9);
|
||||
text-shadow: 0 0 20px rgba(50, 150, 255, 0.5);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#fly-prompt .prompt-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 1px;
|
||||
color: rgba(120, 170, 210, 0.6);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.7; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ---- Controls Help ---- */
|
||||
#controls-help {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 80;
|
||||
text-align: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(100, 200, 255, 0.15);
|
||||
pointer-events: none;
|
||||
font-size: 0.72rem;
|
||||
color: rgba(160, 200, 230, 0.8);
|
||||
line-height: 1.6;
|
||||
transition: opacity 2s ease;
|
||||
}
|
||||
|
||||
#controls-help.fade-out {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ---- Static Controls List ---- */
|
||||
#controls-list {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
bottom: 20px;
|
||||
z-index: 60;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(100, 200, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
pointer-events: none;
|
||||
font-size: 0.62rem;
|
||||
color: rgba(150, 190, 220, 0.6);
|
||||
}
|
||||
|
||||
#controls-list .controls-title {
|
||||
font-size: 0.58rem;
|
||||
color: rgba(120, 170, 210, 0.5);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
#controls-list ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#controls-list li {
|
||||
line-height: 1.55;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#controls-list li span {
|
||||
display: inline-block;
|
||||
width: 52px;
|
||||
color: rgba(120, 210, 255, 0.7);
|
||||
}
|
||||
|
||||
/* ---- Pause Indicator ---- */
|
||||
#pause-indicator {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 95;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
padding: 12px 28px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 220, 120, 0.3);
|
||||
pointer-events: none;
|
||||
font-size: 1.6rem;
|
||||
letter-spacing: 6px;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 220, 120, 0.9);
|
||||
text-shadow: 0 0 20px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
/* ---- Loading Overlay ---- */
|
||||
#loading-overlay {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 90;
|
||||
text-align: center;
|
||||
background: rgba(0, 10, 30, 0.85);
|
||||
padding: 30px 50px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(100, 200, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
#loading-text {
|
||||
font-size: 1rem;
|
||||
color: rgba(150, 200, 240, 0.9);
|
||||
margin-bottom: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
#loading-bar-container {
|
||||
width: 240px;
|
||||
height: 4px;
|
||||
background: rgba(50, 80, 120, 0.5);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#loading-bar {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #2288cc, #44ccff);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
#loading-progress {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(100, 180, 230, 0.7);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ---- Welcome Screen ---- */
|
||||
#welcome {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 70;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#welcome h1 {
|
||||
font-size: 2.2rem;
|
||||
color: rgba(180, 220, 255, 0.9);
|
||||
font-weight: 300;
|
||||
letter-spacing: 4px;
|
||||
text-transform: uppercase;
|
||||
text-shadow: 0 0 30px rgba(50, 150, 255, 0.3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#welcome p {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(130, 170, 210, 0.6);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
#welcome .welcome-hint {
|
||||
margin-top: 20px;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(100, 150, 200, 0.5);
|
||||
animation: pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ---- Agent Status ---- */
|
||||
|
||||
/* Clears the controls help that sits at the bottom edge, so both can be
|
||||
on screen during the five seconds before the help fades */
|
||||
#agent-status {
|
||||
position: fixed;
|
||||
bottom: 64px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 90;
|
||||
max-width: min(560px, calc(100vw - 24px));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 14px;
|
||||
border-radius: 20px;
|
||||
background: rgba(0, 8, 24, 0.8);
|
||||
border: 1px solid rgba(100, 200, 255, 0.25);
|
||||
font-size: 0.75rem;
|
||||
color: rgba(200, 230, 255, 0.9);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#agent-status-dot {
|
||||
flex: 0 0 auto;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #66ddaa;
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* A finished job leaves the last status readable but stops the pulse */
|
||||
#agent-status.idle #agent-status-dot {
|
||||
background: rgba(120, 170, 210, 0.5);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
#agent-status-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#agent-status-tokens {
|
||||
flex: 0 0 auto;
|
||||
color: rgba(120, 200, 255, 0.65);
|
||||
}
|
||||
|
||||
#agent-status-tokens:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- Utility ---- */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ---- Destination Input Dialog ---- */
|
||||
|
||||
/* The "Input Destination" entry is the only action in the menu, so it
|
||||
is set apart from the capitals listed under it. */
|
||||
#opt-input-destination {
|
||||
background: #e8edf1;
|
||||
color: #16283f;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Sits above the top bar so the dialog is never partly behind the menu,
|
||||
and covers the canvas so a click cannot engage flight behind it. The
|
||||
card hangs from just under the bar rather than centering, so it stays
|
||||
next to the menu entry that opened it. */
|
||||
#destination-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
padding-top: calc(var(--top-bar-height, 56px) + 16px);
|
||||
background: rgba(4, 12, 24, 0.45);
|
||||
}
|
||||
|
||||
.destination-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
max-height: calc(100vh - var(--top-bar-height, 56px) - 40px);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
border-radius: 14px;
|
||||
background: rgba(238, 242, 245, 0.97);
|
||||
border: 1px solid rgba(120, 160, 200, 0.45);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
#destination-form,
|
||||
#destination-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dest-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Fixed basis keeps the three fields on one left edge regardless of
|
||||
how wide the individual label text is */
|
||||
.dest-label {
|
||||
flex: 0 0 92px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: #ccd4da;
|
||||
color: #1c2b3d;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dest-row input,
|
||||
.dest-row select {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 9px 12px;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(80, 130, 180, 0.35);
|
||||
background: #16283f;
|
||||
color: #dceaf6;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dest-row input::placeholder {
|
||||
color: rgba(190, 215, 235, 0.55);
|
||||
}
|
||||
|
||||
.dest-row input:focus,
|
||||
.dest-row select:focus {
|
||||
border-color: rgba(120, 200, 255, 0.85);
|
||||
}
|
||||
|
||||
.dest-row select option {
|
||||
background: #16283f;
|
||||
color: #dceaf6;
|
||||
}
|
||||
|
||||
#dest-result-list {
|
||||
list-style: none;
|
||||
max-height: 210px;
|
||||
overflow-y: auto;
|
||||
padding: 6px 4px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(80, 130, 180, 0.35);
|
||||
background: #16283f;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#dest-result-list:focus {
|
||||
border-color: rgba(120, 200, 255, 0.85);
|
||||
}
|
||||
|
||||
/* The dashed rule between rows is a bottom border on every row but the
|
||||
last, so the list reads as separated entries rather than a paragraph */
|
||||
.dest-result {
|
||||
padding: 9px 10px;
|
||||
border-radius: 6px;
|
||||
color: #dceaf6;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px dashed rgba(140, 185, 225, 0.4);
|
||||
}
|
||||
|
||||
.dest-result:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dest-result:hover {
|
||||
background: rgba(90, 140, 190, 0.25);
|
||||
}
|
||||
|
||||
.dest-result.active {
|
||||
background: rgba(110, 165, 215, 0.45);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.dest-message {
|
||||
min-height: 1em;
|
||||
font-size: 0.72rem;
|
||||
color: #7a1f1f;
|
||||
}
|
||||
|
||||
#dest-results-message {
|
||||
color: #33465c;
|
||||
}
|
||||
|
||||
.dest-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dest-actions button {
|
||||
flex: 1 1 0;
|
||||
padding: 10px 12px;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(80, 130, 180, 0.35);
|
||||
background: #16283f;
|
||||
color: #dceaf6;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.dest-actions button:hover:not(:disabled) {
|
||||
background: #21395a;
|
||||
border-color: rgba(120, 200, 255, 0.7);
|
||||
}
|
||||
|
||||
.dest-actions button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---- Selector Panel Buttons ---- */
|
||||
#selector-panel button {
|
||||
padding: 5px 12px;
|
||||
font-size: 0.75rem;
|
||||
font-family: inherit;
|
||||
border: 1px solid rgba(100, 200, 255, 0.3);
|
||||
border-radius: 3px;
|
||||
background: rgba(10, 20, 40, 0.9);
|
||||
color: #c0e0f0;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
#selector-panel button:hover {
|
||||
border-color: rgba(100, 200, 255, 0.6);
|
||||
background: rgba(20, 40, 70, 0.9);
|
||||
}
|
||||
|
||||
#btn-export-json {
|
||||
color: #88ddaa !important;
|
||||
border-color: rgba(100, 255, 160, 0.3) !important;
|
||||
}
|
||||
|
||||
#btn-export-json:hover {
|
||||
border-color: rgba(100, 255, 160, 0.6) !important;
|
||||
}
|
||||
|
||||
/* ---- Render Config Panel ---- */
|
||||
#config-panel {
|
||||
position: fixed;
|
||||
top: calc(var(--top-bar-height, 56px) + 12px);
|
||||
left: 12px;
|
||||
z-index: 100;
|
||||
width: 320px;
|
||||
max-width: calc(100vw - 24px);
|
||||
max-height: calc(100vh - var(--top-bar-height, 56px) - 36px);
|
||||
overflow-y: auto;
|
||||
background: rgba(0, 8, 24, 0.88);
|
||||
padding: 14px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(100, 200, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
#config-panel h3 {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(180, 220, 255, 0.9);
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
#config-panel .config-hint {
|
||||
font-size: 0.65rem;
|
||||
color: rgba(120, 170, 210, 0.6);
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
#config-panel fieldset {
|
||||
border: 1px solid rgba(100, 200, 255, 0.12);
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#config-panel legend {
|
||||
font-size: 0.7rem;
|
||||
color: #66aacc;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.cfg-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.cfg-row label {
|
||||
flex: 0 0 100px;
|
||||
font-size: 0.68rem;
|
||||
color: rgba(160, 200, 230, 0.8);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.cfg-row input[type="range"] {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: rgba(50, 80, 120, 0.5);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cfg-row input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #44ccff;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.cfg-row input[type="range"]::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #44ccff;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.cfg-row input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
accent-color: #44ccff;
|
||||
}
|
||||
|
||||
.cfg-row span {
|
||||
flex: 0 0 50px;
|
||||
font-size: 0.65rem;
|
||||
color: rgba(100, 220, 255, 0.85);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Scrollbar has styling for config panel */
|
||||
#config-panel::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
#config-panel::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#config-panel::-webkit-scrollbar-thumb {
|
||||
background: rgba(100, 200, 255, 0.2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
/************************************************************************************
|
||||
* TerrainFill - Fills unloaded terrain gaps using sampled colors *
|
||||
* from the rendered viewport. *
|
||||
* *
|
||||
* Algorithm: *
|
||||
* 1. Render scene (without fill) to a low-res offscreen target *
|
||||
* 2. Sample 10x10px patches from each of the 4 viewport borders (40 samples) *
|
||||
* 3. Sample 10x10px patches from the middle area (5 samples) *
|
||||
* 4. Sample 5x5px patches from padding perimeter in flight direction (4 samples) *
|
||||
* 5. Filter out placeholder/sky/water colors *
|
||||
* 6. Generate a fill texture by blending sampled colors with value noise *
|
||||
* 7. Apply the texture to a large fill plane between tiles and water *
|
||||
***********************************************************************************/
|
||||
function TerrainFill(renderer, scene, camera) {
|
||||
this.renderer = renderer;
|
||||
this.scene = scene;
|
||||
this.camera = camera;
|
||||
|
||||
// Low-res render target for viewport sampling
|
||||
this.sampleW = 256;
|
||||
this.sampleH = 256;
|
||||
this.sampleTarget = new THREE.WebGLRenderTarget(this.sampleW, this.sampleH);
|
||||
this.sampleBuffer = new Uint8Array(this.sampleW * this.sampleH * 4);
|
||||
|
||||
// Fill texture canvas
|
||||
this.texW = 256;
|
||||
this.texH = 256;
|
||||
this.fillCanvas = document.createElement('canvas');
|
||||
this.fillCanvas.width = this.texW;
|
||||
this.fillCanvas.height = this.texH;
|
||||
this.fillCtx = this.fillCanvas.getContext('2d');
|
||||
|
||||
// Initialize with placeholder color
|
||||
this.fillCtx.fillStyle = '#2a3a2a';
|
||||
this.fillCtx.fillRect(0, 0, this.texW, this.texH);
|
||||
|
||||
// Three.js canvas texture for the fill plane
|
||||
this.fillTexture = new THREE.CanvasTexture(this.fillCanvas);
|
||||
this.fillTexture.wrapS = THREE.RepeatWrapping;
|
||||
this.fillTexture.wrapT = THREE.RepeatWrapping;
|
||||
this.fillTexture.repeat.set(50, 50);
|
||||
|
||||
// Fill plane mesh sitting between tile meshes (y=0) and water (y=-2)
|
||||
var geo = new THREE.PlaneGeometry(30000, 30000);
|
||||
var mat = new THREE.MeshBasicMaterial({ map: this.fillTexture });
|
||||
this.fillMesh = new THREE.Mesh(geo, mat);
|
||||
this.fillMesh.rotation.x = -Math.PI / 2;
|
||||
this.fillMesh.position.y = -0.5;
|
||||
scene.add(this.fillMesh);
|
||||
|
||||
// Collected sample colors (array of {r,g,b} objects)
|
||||
this.sampleColors = [];
|
||||
|
||||
// Timing
|
||||
this.frameCount = 0;
|
||||
this.sampleInterval = 45; // Sample every N frames
|
||||
|
||||
// Time-based filler pattern update interval (seconds)
|
||||
this.updateIntervalSec = 10;
|
||||
this.lastUpdateTime = 0;
|
||||
|
||||
// Configurable patch counts and sizes (overridden by RenderConfig)
|
||||
this.perimeterPatchCount = 10;
|
||||
this.perimeterPatchSize = 10;
|
||||
this.centerPatchCount = 5;
|
||||
this.centerPatchSize = 10;
|
||||
this.paddingPatchCount = 4;
|
||||
this.paddingPatchSize = 5;
|
||||
|
||||
// Cloud overlay effect applied to the fill area
|
||||
this.cloudOverlay = {
|
||||
enabled: true,
|
||||
opacity: 0.35,
|
||||
speed: 0.0004,
|
||||
coverage: 0.5,
|
||||
scale: 0.008,
|
||||
time: 0
|
||||
};
|
||||
|
||||
// Cloud canvas and texture (rendered on top of fill plane)
|
||||
this.cloudCanvas = document.createElement('canvas');
|
||||
this.cloudCanvas.width = this.texW;
|
||||
this.cloudCanvas.height = this.texH;
|
||||
this.cloudCtx = this.cloudCanvas.getContext('2d');
|
||||
|
||||
this.cloudTexture = new THREE.CanvasTexture(this.cloudCanvas);
|
||||
this.cloudTexture.wrapS = THREE.RepeatWrapping;
|
||||
this.cloudTexture.wrapT = THREE.RepeatWrapping;
|
||||
this.cloudTexture.repeat.set(50, 50);
|
||||
|
||||
var cloudGeo = new THREE.PlaneGeometry(30000, 30000);
|
||||
var cloudMat = new THREE.MeshBasicMaterial({
|
||||
map: this.cloudTexture,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
opacity: 1.0
|
||||
});
|
||||
this.cloudMesh = new THREE.Mesh(cloudGeo, cloudMat);
|
||||
this.cloudMesh.rotation.x = -Math.PI / 2;
|
||||
this.cloudMesh.position.y = -0.3;
|
||||
scene.add(this.cloudMesh);
|
||||
|
||||
// Pre-compute noise permutation table
|
||||
this.perm = new Array(512);
|
||||
var p = new Array(256);
|
||||
for (var i = 0; i < 256; i++) p[i] = i;
|
||||
for (var i = 255; i > 0; i--) {
|
||||
var j = Math.floor(Math.random() * (i + 1));
|
||||
var tmp = p[i]; p[i] = p[j]; p[j] = tmp;
|
||||
}
|
||||
for (var i = 0; i < 512; i++) this.perm[i] = p[i & 255];
|
||||
|
||||
// Second permutation for cloud noise (different seed)
|
||||
this.cloudPerm = new Array(512);
|
||||
var cp = new Array(256);
|
||||
for (var i = 0; i < 256; i++) cp[i] = i;
|
||||
for (var i = 255; i > 0; i--) {
|
||||
var j = Math.floor(Math.random() * (i + 1));
|
||||
var tmp = cp[i]; cp[i] = cp[j]; cp[j] = tmp;
|
||||
}
|
||||
for (var i = 0; i < 512; i++) this.cloudPerm[i] = cp[i & 255];
|
||||
|
||||
// Pre-compute noise map (noise values won't change, only color mapping does)
|
||||
this.noiseMap = new Float32Array(this.texW * this.texH);
|
||||
for (var y = 0; y < this.texH; y++) {
|
||||
for (var x = 0; x < this.texW; x++) {
|
||||
var n1 = this._noise2d(x * 0.012, y * 0.012);
|
||||
var n2 = this._noise2d(x * 0.035 + 97.3, y * 0.035 + 97.3) * 0.5;
|
||||
var n3 = this._noise2d(x * 0.08 + 231.7, y * 0.08 + 231.7) * 0.25;
|
||||
this.noiseMap[y * this.texW + x] = (n1 + n2 + n3) / 1.75;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called each frame after main render. Periodically samples the viewport
|
||||
* and regenerates the fill texture.
|
||||
*/
|
||||
TerrainFill.prototype.update = function (flightHeading) {
|
||||
// Keep fill plane centered on camera
|
||||
this.fillMesh.position.x = this.camera.position.x;
|
||||
this.fillMesh.position.z = this.camera.position.z;
|
||||
|
||||
// Keep cloud mesh centered on camera
|
||||
this.cloudMesh.position.x = this.camera.position.x;
|
||||
this.cloudMesh.position.z = this.camera.position.z;
|
||||
|
||||
this.frameCount++;
|
||||
if (this.frameCount % this.sampleInterval !== 0) return;
|
||||
|
||||
// Collect viewport samples every sampleInterval frames
|
||||
this._collectSamples(flightHeading);
|
||||
|
||||
// Regenerate fill texture on a time-based interval (seconds)
|
||||
var now = performance.now() / 1000;
|
||||
if (this.sampleColors.length > 20 && (now - this.lastUpdateTime) >= this.updateIntervalSec) {
|
||||
this._generateFillTexture();
|
||||
this.lastUpdateTime = now;
|
||||
}
|
||||
|
||||
// Update cloud effect
|
||||
this._updateCloud();
|
||||
};
|
||||
|
||||
/**
|
||||
* Samples colors from the rendered viewport at border, middle,
|
||||
* and padding perimeter positions.
|
||||
*/
|
||||
TerrainFill.prototype._collectSamples = function (flightHeading) {
|
||||
// Hide generated overlays so we only sample actual terrain + sky
|
||||
var cloudWasVisible = this.cloudMesh.visible;
|
||||
this.fillMesh.visible = false;
|
||||
this.cloudMesh.visible = false;
|
||||
|
||||
// Render scene to low-res offscreen target
|
||||
this.renderer.setRenderTarget(this.sampleTarget);
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
this.renderer.setRenderTarget(null);
|
||||
|
||||
this.fillMesh.visible = true;
|
||||
this.cloudMesh.visible = cloudWasVisible;
|
||||
|
||||
// Read pixels from the render target
|
||||
this.renderer.readRenderTargetPixels(
|
||||
this.sampleTarget, 0, 0,
|
||||
this.sampleW, this.sampleH,
|
||||
this.sampleBuffer
|
||||
);
|
||||
|
||||
var w = this.sampleW;
|
||||
var h = this.sampleH;
|
||||
var buf = this.sampleBuffer;
|
||||
var self = this;
|
||||
this.sampleColors = [];
|
||||
|
||||
// Helper: extract valid (non-placeholder/sky/water) colors from a patch
|
||||
function extractPatch(px, py, size) {
|
||||
var result = [];
|
||||
for (var dy = 0; dy < size; dy++) {
|
||||
for (var dx = 0; dx < size; dx++) {
|
||||
var sx = Math.max(0, Math.min(px + dx, w - 1));
|
||||
var sy = Math.max(0, Math.min(py + dy, h - 1));
|
||||
var idx = (sy * w + sx) * 4;
|
||||
var r = buf[idx], g = buf[idx + 1], b = buf[idx + 2];
|
||||
if (!self._isSkipColor(r, g, b)) {
|
||||
result.push({ r: r, g: g, b: b });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
var borderDepth = Math.max(Math.floor(h * 0.08), 8);
|
||||
var patchSize = this.perimeterPatchSize;
|
||||
var smallPatch = this.paddingPatchSize;
|
||||
var perimCount = this.perimeterPatchCount;
|
||||
var centerCount = this.centerPatchCount;
|
||||
var centerSize = this.centerPatchSize;
|
||||
var padCount = this.paddingPatchCount;
|
||||
|
||||
// ---- Border samples: perimCount per border ----
|
||||
|
||||
// Top border
|
||||
for (var i = 0; i < perimCount; i++) {
|
||||
var x = Math.floor(Math.random() * (w - patchSize));
|
||||
var y = Math.floor(Math.random() * borderDepth);
|
||||
this.sampleColors = this.sampleColors.concat(extractPatch(x, y, patchSize));
|
||||
}
|
||||
// Bottom border
|
||||
for (var i = 0; i < perimCount; i++) {
|
||||
var x = Math.floor(Math.random() * (w - patchSize));
|
||||
var y = h - borderDepth + Math.floor(Math.random() * Math.max(1, borderDepth - patchSize));
|
||||
this.sampleColors = this.sampleColors.concat(extractPatch(x, y, patchSize));
|
||||
}
|
||||
// Left border
|
||||
for (var i = 0; i < perimCount; i++) {
|
||||
var x = Math.floor(Math.random() * borderDepth);
|
||||
var y = Math.floor(Math.random() * (h - patchSize));
|
||||
this.sampleColors = this.sampleColors.concat(extractPatch(x, y, patchSize));
|
||||
}
|
||||
// Right border
|
||||
for (var i = 0; i < perimCount; i++) {
|
||||
var x = w - borderDepth + Math.floor(Math.random() * Math.max(1, borderDepth - patchSize));
|
||||
var y = Math.floor(Math.random() * (h - patchSize));
|
||||
this.sampleColors = this.sampleColors.concat(extractPatch(x, y, patchSize));
|
||||
}
|
||||
|
||||
// ---- Middle area: centerCount samples ----
|
||||
var midRange = Math.min(w, h) / 4;
|
||||
for (var i = 0; i < centerCount; i++) {
|
||||
var x = Math.floor(w / 2 - midRange / 2 + Math.random() * midRange);
|
||||
var y = Math.floor(h / 2 - midRange / 2 + Math.random() * midRange);
|
||||
this.sampleColors = this.sampleColors.concat(extractPatch(x, y, centerSize));
|
||||
}
|
||||
|
||||
// ---- Padding perimeter: padCount samples toward flight direction ----
|
||||
var headRad = ((flightHeading || 0)) * Math.PI / 180;
|
||||
var dirX = Math.sin(headRad);
|
||||
var dirY = -Math.cos(headRad);
|
||||
for (var i = 0; i < padCount; i++) {
|
||||
var dist = 0.25 + Math.random() * 0.35;
|
||||
var spread = (Math.random() - 0.5) * 0.4;
|
||||
var sx = Math.floor(w / 2 + (dirX * dist + dirY * spread) * w * 0.45);
|
||||
var sy = Math.floor(h / 2 + (dirY * dist - dirX * spread) * h * 0.45);
|
||||
sx = Math.max(0, Math.min(sx, w - smallPatch));
|
||||
sy = Math.max(0, Math.min(sy, h - smallPatch));
|
||||
this.sampleColors = this.sampleColors.concat(extractPatch(sx, sy, smallPatch));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether a pixel color should be skipped during sampling
|
||||
* (placeholder tiles, sky, water, or black).
|
||||
*/
|
||||
TerrainFill.prototype._isSkipColor = function (r, g, b) {
|
||||
// Placeholder dark green: #2a3a2a (42, 58, 42)
|
||||
if (Math.abs(r - 42) < 20 && Math.abs(g - 58) < 20 && Math.abs(b - 42) < 20) return true;
|
||||
// Sky blue: #87CEEB (135, 206, 235)
|
||||
if (Math.abs(r - 135) < 35 && Math.abs(g - 206) < 35 && Math.abs(b - 235) < 35) return true;
|
||||
// Water blue: #1a4a7a (26, 74, 122)
|
||||
if (Math.abs(r - 26) < 25 && Math.abs(g - 74) < 25 && Math.abs(b - 122) < 25) return true;
|
||||
// Near-black
|
||||
if (r < 8 && g < 8 && b < 8) return true;
|
||||
// Fill plane color itself (dark placeholder)
|
||||
if (r < 50 && g < 65 && b < 50 && Math.abs(r - b) < 10) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates the fill texture by mapping sampled colors through
|
||||
* a pre-computed noise map. Produces a natural-looking blend of
|
||||
* terrain colors that fills gaps between loaded tiles.
|
||||
*/
|
||||
TerrainFill.prototype._generateFillTexture = function () {
|
||||
var colors = this.sampleColors;
|
||||
if (colors.length < 10) return;
|
||||
|
||||
var ctx = this.fillCtx;
|
||||
var w = this.texW;
|
||||
var h = this.texH;
|
||||
var imgData = ctx.createImageData(w, h);
|
||||
var data = imgData.data;
|
||||
var numColors = colors.length;
|
||||
|
||||
for (var y = 0; y < h; y++) {
|
||||
for (var x = 0; x < w; x++) {
|
||||
// Look up pre-computed noise value (0-1)
|
||||
var noise = this.noiseMap[y * w + x];
|
||||
|
||||
// Map noise to color index range and blend between neighbors
|
||||
var fidx = noise * (numColors - 1);
|
||||
var idx1 = Math.floor(fidx);
|
||||
var idx2 = Math.min(idx1 + 1, numColors - 1);
|
||||
var blend = fidx - idx1;
|
||||
|
||||
var c1 = colors[idx1];
|
||||
var c2 = colors[idx2];
|
||||
|
||||
var px = (y * w + x) * 4;
|
||||
data[px] = Math.round(c1.r + (c2.r - c1.r) * blend);
|
||||
data[px + 1] = Math.round(c1.g + (c2.g - c1.g) * blend);
|
||||
data[px + 2] = Math.round(c1.b + (c2.b - c1.b) * blend);
|
||||
data[px + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
this.fillTexture.needsUpdate = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 2D value noise using the pre-computed permutation table.
|
||||
*/
|
||||
TerrainFill.prototype._noise2d = function (x, y) {
|
||||
var xi = Math.floor(x) & 255;
|
||||
var yi = Math.floor(y) & 255;
|
||||
var xf = x - Math.floor(x);
|
||||
var yf = y - Math.floor(y);
|
||||
|
||||
// Smoothstep fade curves
|
||||
var u = xf * xf * (3 - 2 * xf);
|
||||
var v = yf * yf * (3 - 2 * yf);
|
||||
|
||||
// Hash four corners
|
||||
var aa = this.perm[this.perm[xi] + yi] / 255;
|
||||
var ab = this.perm[this.perm[xi] + yi + 1] / 255;
|
||||
var ba = this.perm[this.perm[xi + 1] + yi] / 255;
|
||||
var bb = this.perm[this.perm[xi + 1] + yi + 1] / 255;
|
||||
|
||||
// Bilinear interpolation
|
||||
var x1 = aa * (1 - u) + ba * u;
|
||||
var x2 = ab * (1 - u) + bb * u;
|
||||
return x1 * (1 - v) + x2 * v;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates animated cloud overlay texture using time-offset noise.
|
||||
* The cloud effect applies a semi-transparent white layer over the
|
||||
* fill area, simulating drifting cloud cover.
|
||||
*/
|
||||
TerrainFill.prototype._updateCloud = function () {
|
||||
if (!this.cloudOverlay.enabled) {
|
||||
this.cloudMesh.visible = false;
|
||||
return;
|
||||
}
|
||||
this.cloudMesh.visible = true;
|
||||
this.cloudOverlay.time += this.cloudOverlay.speed * this.sampleInterval;
|
||||
|
||||
var ctx = this.cloudCtx;
|
||||
var w = this.texW;
|
||||
var h = this.texH;
|
||||
var imgData = ctx.createImageData(w, h);
|
||||
var data = imgData.data;
|
||||
var t = this.cloudOverlay.time;
|
||||
var scale = this.cloudOverlay.scale;
|
||||
var coverage = this.cloudOverlay.coverage;
|
||||
var opacity = this.cloudOverlay.opacity;
|
||||
|
||||
for (var y = 0; y < h; y++) {
|
||||
for (var x = 0; x < w; x++) {
|
||||
// Multi-octave noise with time offset for drift
|
||||
var n1 = this._cloudNoise2d(x * scale + t, y * scale + t * 0.7);
|
||||
var n2 = this._cloudNoise2d(x * scale * 2.3 + 50 + t * 1.3, y * scale * 2.3 + 50 + t * 0.9) * 0.5;
|
||||
var n3 = this._cloudNoise2d(x * scale * 5.1 + 120 - t * 0.5, y * scale * 5.1 + 120 + t * 0.3) * 0.25;
|
||||
var noise = (n1 + n2 + n3) / 1.75;
|
||||
|
||||
// Apply coverage threshold: lower coverage = fewer clouds
|
||||
var cloudDensity = Math.max(0, noise - (1 - coverage)) / coverage;
|
||||
cloudDensity = Math.min(1, cloudDensity);
|
||||
|
||||
// Smooth edges
|
||||
cloudDensity = cloudDensity * cloudDensity * (3 - 2 * cloudDensity);
|
||||
|
||||
var alpha = Math.round(cloudDensity * opacity * 255);
|
||||
var px = (y * w + x) * 4;
|
||||
data[px] = 255; // White clouds
|
||||
data[px + 1] = 255;
|
||||
data[px + 2] = 255;
|
||||
data[px + 3] = alpha;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
this.cloudTexture.needsUpdate = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 2D value noise using the cloud-specific permutation table.
|
||||
*/
|
||||
TerrainFill.prototype._cloudNoise2d = function (x, y) {
|
||||
var xi = Math.floor(x) & 255;
|
||||
var yi = Math.floor(y) & 255;
|
||||
var xf = x - Math.floor(x);
|
||||
var yf = y - Math.floor(y);
|
||||
|
||||
var u = xf * xf * (3 - 2 * xf);
|
||||
var v = yf * yf * (3 - 2 * yf);
|
||||
|
||||
var aa = this.cloudPerm[this.cloudPerm[xi] + yi] / 255;
|
||||
var ab = this.cloudPerm[this.cloudPerm[xi] + yi + 1] / 255;
|
||||
var ba = this.cloudPerm[this.cloudPerm[xi + 1] + yi] / 255;
|
||||
var bb = this.cloudPerm[this.cloudPerm[xi + 1] + yi + 1] / 255;
|
||||
|
||||
var x1 = aa * (1 - u) + ba * u;
|
||||
var x2 = ab * (1 - u) + bb * u;
|
||||
return x1 * (1 - v) + x2 * v;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset fill state when switching capitals.
|
||||
*/
|
||||
TerrainFill.prototype.reset = function () {
|
||||
this.sampleColors = [];
|
||||
this.frameCount = 0;
|
||||
this.fillCtx.fillStyle = '#2a3a2a';
|
||||
this.fillCtx.fillRect(0, 0, this.texW, this.texH);
|
||||
this.fillTexture.needsUpdate = true;
|
||||
this.cloudOverlay.time = 0;
|
||||
this.cloudCtx.clearRect(0, 0, this.texW, this.texH);
|
||||
this.cloudTexture.needsUpdate = true;
|
||||
};
|
||||
@@ -0,0 +1,475 @@
|
||||
{
|
||||
"name": "flight-map-canvas",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flight-map-canvas",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "latest"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot": {
|
||||
"version": "1.0.75",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.75.tgz",
|
||||
"integrity": "sha512-rn7ZQmhydCZ9XRdG6V78QEhXIdYlChlUvOVAtyJ6KHJGt2O9/71si7PCt84amfmg0N7IXBT2UGRYF4GQDQBt+g==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.1.2"
|
||||
},
|
||||
"bin": {
|
||||
"copilot": "npm-loader.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@github/copilot-darwin-arm64": "1.0.75",
|
||||
"@github/copilot-darwin-x64": "1.0.75",
|
||||
"@github/copilot-linux-arm64": "1.0.75",
|
||||
"@github/copilot-linux-x64": "1.0.75",
|
||||
"@github/copilot-linuxmusl-arm64": "1.0.75",
|
||||
"@github/copilot-linuxmusl-x64": "1.0.75",
|
||||
"@github/copilot-win32-arm64": "1.0.75",
|
||||
"@github/copilot-win32-x64": "1.0.75"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-arm64": {
|
||||
"version": "1.0.75",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.75.tgz",
|
||||
"integrity": "sha512-avt2ganQwtycGkqz9z8JpARoEz2Fzi02O+WitbB8I8iF3jfvEGpQ8k3z9oSCPAAO0rIMBeSl05xEUUV5RJwfRA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-darwin-arm64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-x64": {
|
||||
"version": "1.0.75",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.75.tgz",
|
||||
"integrity": "sha512-eyM5cz83d9h05m6cnnG7tIHR4lSHHXUnxXeQm4rU3nGB1uwHWOa1TH9B4uAaW3zKQgkCkNgm+IlcPJ7geM0KBg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-darwin-x64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linux-arm64": {
|
||||
"version": "1.0.75",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.75.tgz",
|
||||
"integrity": "sha512-PuGjPCy+z7DrD0rkcXQRBzoA8lVxD1WC6UeZxkZaIb9asueE798eqHHMV169zYiA3gsiSegIKu5J/aUeWwhpuQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-linux-arm64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linux-x64": {
|
||||
"version": "1.0.75",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.75.tgz",
|
||||
"integrity": "sha512-aGDtUrNyj/ubqLfXOomL4XYZR//1Cs4tDGe7r/464e3Nw4WlNxWPjBbNyOCcUPUMbPHXck6TyxjEvBwwlKCK5w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-linux-x64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linuxmusl-arm64": {
|
||||
"version": "1.0.75",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.75.tgz",
|
||||
"integrity": "sha512-4cbjjR/laL+4a0VjMkzjIsprRUxiAHAefWVTppFitKiqNxfgyRuOhfSUPUA5TVxPwfyYXknsxeYSNRL8zu9YDA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-linuxmusl-arm64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linuxmusl-x64": {
|
||||
"version": "1.0.75",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.75.tgz",
|
||||
"integrity": "sha512-I4oFPNOULMGxWghGCG/VkiT0a62UiL4XGKSlp6X0rVD9xyoYyiLGlsBmTumrooPbzEvgKICYWIz8PCZjrZTyhA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-linuxmusl-x64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-sdk": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.8.tgz",
|
||||
"integrity": "sha512-dbahVsyt2aX8qqtOOtmYNe40MnvzSvOSHYFFgoFK7gHZSTNz9QgOht8b1sCCJlcXaFAn/w+5qNc7CwWoCjpQ0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@github/copilot": "^1.0.73",
|
||||
"koffi": "^3.1.0",
|
||||
"vscode-jsonrpc": "^8.2.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-arm64": {
|
||||
"version": "1.0.75",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.75.tgz",
|
||||
"integrity": "sha512-wlLDuor+Qm1H43VwZNi03QI4H9FSyZoQzE/YHpGhUay7sKA+4cDJF8ZZNGrtYkyPskfH5HCdNygUn7EEt6lRyQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-win32-arm64": "copilot.exe"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-x64": {
|
||||
"version": "1.0.75",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.75.tgz",
|
||||
"integrity": "sha512-TKISaaILkgcOi7ThaTnln1XoasbUIx+HKSb+pf678RvGucIfLohQyLceuq+rTykoynX54KQ0pf9TEUbMVkUNAA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-win32-x64": "copilot.exe"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-darwin-arm64": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.1.2.tgz",
|
||||
"integrity": "sha512-32pU4pNZABIz+l9DNJl51Y+jur4vv+SF4Ip2CSF4OUg1xUyefoLpX0NttDmzGITIrneUEVSEN+dT22524ESKBw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-darwin-x64": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.1.2.tgz",
|
||||
"integrity": "sha512-S+H6LQgUoMj77BqDegwlRaxwLXDfwvSJGuceOqtH0I5V8rzKLmu/hC7NBlxOoAlvKlcV63FtdNiE2E9YSltffg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-freebsd-arm64": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.1.2.tgz",
|
||||
"integrity": "sha512-fD0ow2PBE60nw7K6xcbala6qwXxfcYeU62tduNeIPvx0KoWhU2rMKZiDNe+iI5TQb3rxYYjjP+aF2Sdm9y6EXQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-freebsd-ia32": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.1.2.tgz",
|
||||
"integrity": "sha512-t8OmL+hoJGDLZDnuLjgLemSYrXX99M7Md+zJX8bMHOtiNbFtkGXn/mV21Pb1ik9JhBXjwK1r4hvBPNlqTMGrHg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-freebsd-x64": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.1.2.tgz",
|
||||
"integrity": "sha512-axbLgiM4Y2vyDOTqlXCI8vkg9wqjwSRsmoWXSKreA5YFJwnYA6Sc4aHMz+qZgUSfFei52Qrv1RGhDyo4kHvqhA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-linux-arm64": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.1.2.tgz",
|
||||
"integrity": "sha512-f0hqAIlFcL9wlRGJ/uCfyfspqnGaASk2gLx1UAP3RBgMQl68D1e+fiHNdXa7g9d76ttmpA8/PGNAqc1X4Byy1Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-linux-ia32": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.1.2.tgz",
|
||||
"integrity": "sha512-UGLPuqeOV/UArsK6oeB5yI/XjSWkFqFlBTC9rUbezBuHJhSibk1EMv7QC0cvtDMu18bo+ucqXWPzh42oT5yYlw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-linux-loong64": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.1.2.tgz",
|
||||
"integrity": "sha512-jI0+gM2oDsJ7reOt3XPyO7lyQtZ1CT6NR2uqGQcQVM43cyXBAVYYCUxEH3LHCbgumFaZ+LueIUgbMSwb9pHBxQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-linux-riscv64": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.1.2.tgz",
|
||||
"integrity": "sha512-yB99adXBRd5T+xXG+f6nnUkC3jCI0iXvPU6RqD9Kx7aZP4Y4NNUWJ5Q4FaP9jb1XmZLY4pGBUiHt8u03Yl7NyA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-linux-x64": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.1.2.tgz",
|
||||
"integrity": "sha512-Oxvo6F3Edzy/Jm2EtbHWkJ2xRB0mXDAe63k5+USL5uiGE5xZjwEUDOBKIhv2BpCZSOAJrfoojFFogj6+ICKQhw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-openbsd-ia32": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.1.2.tgz",
|
||||
"integrity": "sha512-SSWzUhL8Ex84JTsO67+MdWZrdwgOzoOrQ0+ZbB+UsivHoAxmWLHKWZaSafNqyBZtxGY1EgtR8AIPouWE9U+Zfw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-openbsd-x64": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.1.2.tgz",
|
||||
"integrity": "sha512-0ZuI4St7chq3M0d3VivvKIqacZ7RhgohdR476V3HpJkaNdfIywsJIw+GBvqkQahu+4A2Rpu6yQJpWSrfk/Z+Jw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-win32-arm64": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.1.2.tgz",
|
||||
"integrity": "sha512-8Wn6phw7y53uI52+aBPAqEfZ5pj/HCjg/YtdthqSWYHy+d0MhyASKlcmuP0B5raxQnnA1Bm9LC8UO3M3RojeBw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-win32-ia32": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.1.2.tgz",
|
||||
"integrity": "sha512-FkKaPBMawgHMNnp1FwLldXMNvEa139GXkxPi9JD9xU71Kh/ZmuEYHGSD6JwZDmDr4jekVrBrr+eGZ+j6C2mkXg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/@koromix/koffi-win32-x64": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.1.2.tgz",
|
||||
"integrity": "sha512-FeFC59UU1XX4J3ZaqKrsrEzczzB5qksMJo7/R45vIg8mGNVSLMVE85JRiZpjcp9i5Lbav5Vw47QvwFzBgIfvlw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/koffi": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.2.tgz",
|
||||
"integrity": "sha512-wVwuE21TBl8/si6E0hPorKR2PJ2q33mEWVETANrtSp3kFM8fi2FcD/J5wmxu0T4TBcqmMQ4xKuF1X1ayFmphzw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://liberapay.com/Koromix"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@koromix/koffi-darwin-arm64": "3.1.2",
|
||||
"@koromix/koffi-darwin-x64": "3.1.2",
|
||||
"@koromix/koffi-freebsd-arm64": "3.1.2",
|
||||
"@koromix/koffi-freebsd-ia32": "3.1.2",
|
||||
"@koromix/koffi-freebsd-x64": "3.1.2",
|
||||
"@koromix/koffi-linux-arm64": "3.1.2",
|
||||
"@koromix/koffi-linux-ia32": "3.1.2",
|
||||
"@koromix/koffi-linux-loong64": "3.1.2",
|
||||
"@koromix/koffi-linux-riscv64": "3.1.2",
|
||||
"@koromix/koffi-linux-x64": "3.1.2",
|
||||
"@koromix/koffi-openbsd-ia32": "3.1.2",
|
||||
"@koromix/koffi-openbsd-x64": "3.1.2",
|
||||
"@koromix/koffi-win32-arm64": "3.1.2",
|
||||
"@koromix/koffi-win32-ia32": "3.1.2",
|
||||
"@koromix/koffi-win32-x64": "3.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-jsonrpc": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz",
|
||||
"integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "flight-map-canvas",
|
||||
"version": "1.0.0",
|
||||
"main": "extension.mjs",
|
||||
"author": "John Haugabook",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "latest"
|
||||
},
|
||||
"description": "A GitHub Copilot canvas that generates a view where Google Maps can be explored using 3D controls, as if a flight simulator. Agents can send the flight anywhere and report what they are working on.",
|
||||
"keywords": [
|
||||
"copilot-canvas",
|
||||
"flight-simulator",
|
||||
"geography",
|
||||
"google-maps",
|
||||
"interactive-canvas",
|
||||
"session-breaks",
|
||||
"threejs"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"map": {
|
||||
"width": 2210,
|
||||
"height": 1290,
|
||||
"tileZoom": 15,
|
||||
"tileSize": 100,
|
||||
"loadRadius": 25,
|
||||
"unloadRadius": 9,
|
||||
"startAltitude": 200,
|
||||
"fogDensity": 0.00092,
|
||||
"radiusFeather": 28.5,
|
||||
"maxTiles": 600
|
||||
},
|
||||
"filler": {
|
||||
"sampleInterval": 55,
|
||||
"updateIntervalSec": 15,
|
||||
"perimeter": {
|
||||
"patchCount": 15,
|
||||
"patchSize": 15
|
||||
},
|
||||
"center": {
|
||||
"patchCount": 4,
|
||||
"patchSize": 7
|
||||
},
|
||||
"padding": {
|
||||
"patchCount": 6,
|
||||
"patchSize": 9
|
||||
}
|
||||
},
|
||||
"cloud": {
|
||||
"enabled": true,
|
||||
"opacity": 0.35,
|
||||
"speed": 0.0004,
|
||||
"coverage": 0.5,
|
||||
"scale": 0.008
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "pr-artifact-explorer",
|
||||
"description": "Navigate pull requests and securely explore GitHub Actions artifacts, including test results, static sites, terminal recordings, and source files.",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "David Pine",
|
||||
"url": "https://github.com/IEvangelist"
|
||||
},
|
||||
"keywords": [
|
||||
"actions-artifacts",
|
||||
"artifact-browser",
|
||||
"canvas",
|
||||
"copilot-extension",
|
||||
"github-actions",
|
||||
"pull-requests",
|
||||
"test-results"
|
||||
],
|
||||
"logo": "assets/preview.png",
|
||||
"extensions": "."
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# Artifact Explorer
|
||||
|
||||
> **GitHub, the missing parts:** GitHub Actions makes artifacts easy to produce, but pull-request reviewers still have to download opaque ZIP files and inspect them out of context. Artifact Explorer turns those artifacts into a fast, GitHub-native, navigable experience inside Copilot.
|
||||
|
||||
Artifact Explorer is a community-built Copilot canvas extension with the internal extension ID `pr-artifact-explorer`. It is not an official GitHub product or endorsed feature.
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/preview-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/preview.png">
|
||||
<img alt="Artifact Explorer showing pull request workflow artifacts in a responsive GitHub-style interface" src="assets/preview.png">
|
||||
</picture>
|
||||
|
||||
## What it does
|
||||
|
||||
- Navigates repositories, pull requests, artifacts, and files with persistent breadcrumbs.
|
||||
- Loads pull requests progressively, caches result pages, and supports query filters for fast review.
|
||||
- Bounds artifact discovery to the newest 30 workflow runs per head commit and reports when older runs are omitted.
|
||||
- Downloads an artifact once, indexes its ZIP, and streams entries directly from the archive without extracting them to disk.
|
||||
- Previews root-level static sites in a sandboxed loopback frame when the artifact contains `index.html`.
|
||||
- Plays asciinema terminal recordings and renders structured TRX test reports.
|
||||
- Syntax-highlights JSON and XML, previews images and PDFs, renders text with safe escaping, and identifies binary content.
|
||||
- Keeps large files available for direct download and falls back to the original artifact archive when a ZIP entry uses an unsupported encoding.
|
||||
- Provides per-artifact cleanup, clear-all cleanup, and a cache usage view.
|
||||
- Uses a responsive Primer interface that follows Copilot's light and dark themes.
|
||||
|
||||
## Security and local data
|
||||
|
||||
Artifact Explorer inherits eligible GitHub accounts from the Copilot app, GitHub CLI, or process environment. Tokens remain inside the extension process: the canvas receives only sanitized account metadata, while authenticated GitHub API requests are made by the local extension server.
|
||||
|
||||
Artifact ZIPs and preferences are stored under:
|
||||
|
||||
```text
|
||||
$COPILOT_HOME/extensions/pr-artifact-explorer/artifacts/
|
||||
```
|
||||
|
||||
When `COPILOT_HOME` is unset, it defaults to `~/.copilot`. Cached archives remain local until removed through the per-artifact or clear-all controls.
|
||||
|
||||
The canvas server validates its canonical loopback host and requires a per-instance capability token for API, artifact-content, and event requests. Static-site previews bind to a separate ephemeral `127.0.0.1` port, validate their loopback host, apply a restrictive content security policy, prevent path traversal, and stream files from the ZIP. A preview requires `index.html` at the artifact root. Static content is untrusted, remains sandboxed by the canvas, and has no access to GitHub tokens.
|
||||
|
||||
## Install
|
||||
|
||||
Ask Copilot to install the committed extension:
|
||||
|
||||
```text
|
||||
Install this extension: https://github.com/github/awesome-copilot/tree/main/extensions/pr-artifact-explorer
|
||||
```
|
||||
|
||||
You can also copy the folder to one of the supported extension locations:
|
||||
|
||||
- `~/.copilot/extensions/pr-artifact-explorer/` for user scope
|
||||
- `.github/extensions/pr-artifact-explorer/` for project scope
|
||||
|
||||
Reload extensions, then ask Copilot to open the `pr-artifact-explorer` canvas. You can optionally provide a repository (`owner/name`), pull request number, or GitHub pull request URL when opening it.
|
||||
|
||||
## Agent actions
|
||||
|
||||
- `open_pull_request { repository, pullNumber }` - navigate an open canvas to a pull request.
|
||||
- `inspect_artifact { repository, artifactId }` - download, index, detect, and open an Actions artifact.
|
||||
- `cache_status` - report local artifact cache usage.
|
||||
- `clear_cache { artifactId? }` - remove one cached artifact or clear the entire cache.
|
||||
- `accounts` - list sanitized GitHub account metadata available to the extension.
|
||||
- `set_account { id }` - select the GitHub account used for repository and artifact requests.
|
||||
|
||||
## Included third-party components
|
||||
|
||||
The extension bundles Primer CSS, Octicons, and the asciinema player for offline rendering. Their notices are preserved in `third-party-licenses/`.
|
||||
@@ -0,0 +1,329 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { GITHUB_API, USER_AGENT } from "./constants.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const RESOLUTION_CACHE_LIMIT = 20;
|
||||
const SOURCE_PRIORITY = Object.freeze({ copilot: 30, gh: 20, env: 10 });
|
||||
const SOURCE_LABEL = Object.freeze({
|
||||
copilot: "Copilot app",
|
||||
gh: "GitHub CLI",
|
||||
env: "Environment",
|
||||
});
|
||||
|
||||
const cachedResolutions = new Map();
|
||||
|
||||
function cleanGhEnvironment() {
|
||||
const env = { ...process.env };
|
||||
delete env.GH_TOKEN;
|
||||
delete env.GITHUB_TOKEN;
|
||||
return env;
|
||||
}
|
||||
|
||||
function tokenHash(token) {
|
||||
return createHash("sha256").update(token).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
function decodeCopilotLabel(variableName) {
|
||||
let encoded = variableName.replace(/^COPILOT_GH_ACCOUNT_/, "");
|
||||
encoded = encoded.replace(/_([0-9A-Fa-f]{2})_/g, (_, hex) =>
|
||||
String.fromCharCode(Number.parseInt(hex, 16)));
|
||||
return encoded.replace(/^github\.com_/, "") || null;
|
||||
}
|
||||
|
||||
async function listGhAccounts() {
|
||||
let output = "";
|
||||
try {
|
||||
const result = await execFileAsync("gh", ["auth", "status"], {
|
||||
timeout: 8_000,
|
||||
env: cleanGhEnvironment(),
|
||||
windowsHide: true,
|
||||
});
|
||||
output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return [];
|
||||
output = `${error?.stdout ?? ""}\n${error?.stderr ?? ""}`;
|
||||
if (!output.trim()) return [];
|
||||
}
|
||||
|
||||
const accounts = [];
|
||||
const seen = new Set();
|
||||
const pattern = /Logged in to (\S+) account (\S+)/g;
|
||||
let match;
|
||||
while ((match = pattern.exec(output)) !== null) {
|
||||
const key = `${match[1].toLowerCase()}/${match[2].toLowerCase()}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
accounts.push({ host: match[1], login: match[2] });
|
||||
}
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
|
||||
async function readGhToken(host, login) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"gh",
|
||||
["auth", "token", "--hostname", host, "--user", login],
|
||||
{
|
||||
timeout: 8_000,
|
||||
env: cleanGhEnvironment(),
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
return stdout.trim() || null;
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function detectCandidates() {
|
||||
const candidates = [];
|
||||
const seenTokens = new Set();
|
||||
const add = (source, token, hintedLogin) => {
|
||||
if (!token) return;
|
||||
const hash = tokenHash(token);
|
||||
if (seenTokens.has(hash)) return;
|
||||
seenTokens.add(hash);
|
||||
candidates.push({ source, token, hash, hintedLogin: hintedLogin ?? null });
|
||||
};
|
||||
|
||||
for (const [name, value] of Object.entries(process.env)) {
|
||||
if (name.startsWith("COPILOT_GH_ACCOUNT_") && value) {
|
||||
add("copilot", value, decodeCopilotLabel(name));
|
||||
}
|
||||
}
|
||||
add("env", process.env.GH_TOKEN, null);
|
||||
add("env", process.env.GITHUB_TOKEN, null);
|
||||
|
||||
for (const account of await listGhAccounts()) {
|
||||
if (account.host.toLowerCase() !== "github.com") continue;
|
||||
add("gh", await readGhToken(account.host, account.login), account.login);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function apiProbe(token, path) {
|
||||
const response = await fetch(`${GITHUB_API}${path}`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"User-Agent": USER_AGENT,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
signal: AbortSignal.timeout(12_000),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
return { response, payload };
|
||||
}
|
||||
|
||||
async function probeCandidate(candidate, repository) {
|
||||
const safe = {
|
||||
source: candidate.source,
|
||||
hash: candidate.hash,
|
||||
hintedLogin: candidate.hintedLogin,
|
||||
};
|
||||
|
||||
try {
|
||||
const viewer = await apiProbe(candidate.token, "/user");
|
||||
if (!viewer.response.ok || !viewer.payload?.login) {
|
||||
return {
|
||||
...safe,
|
||||
token: candidate.token,
|
||||
login: candidate.hintedLogin ?? "unknown",
|
||||
avatarUrl: null,
|
||||
status: "failed",
|
||||
repositoryAccess: false,
|
||||
reason: viewer.payload?.message ?? `Authentication failed (${viewer.response.status}).`,
|
||||
scopes: [],
|
||||
};
|
||||
}
|
||||
|
||||
let repositoryAccess = null;
|
||||
let repositoryReason = null;
|
||||
if (repository) {
|
||||
const access = await apiProbe(
|
||||
candidate.token,
|
||||
`/repos/${repository.split("/").map(encodeURIComponent).join("/")}`,
|
||||
);
|
||||
repositoryAccess = access.response.ok;
|
||||
if (!repositoryAccess) {
|
||||
repositoryReason = access.payload?.message ?? `Repository check failed (${access.response.status}).`;
|
||||
}
|
||||
}
|
||||
|
||||
const scopesHeader = viewer.response.headers.get("x-oauth-scopes") ?? "";
|
||||
return {
|
||||
...safe,
|
||||
token: candidate.token,
|
||||
login: viewer.payload.login,
|
||||
avatarUrl: viewer.payload.avatar_url ?? null,
|
||||
name: viewer.payload.name ?? null,
|
||||
profileUrl: viewer.payload.html_url ?? null,
|
||||
type: viewer.payload.type ?? null,
|
||||
company: viewer.payload.company ?? null,
|
||||
status: repositoryAccess === false ? "limited" : "ok",
|
||||
repositoryAccess,
|
||||
reason: repositoryReason,
|
||||
scopes: scopesHeader.split(",").map((scope) => scope.trim()).filter(Boolean),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...safe,
|
||||
token: candidate.token,
|
||||
login: candidate.hintedLogin ?? "unknown",
|
||||
avatarUrl: null,
|
||||
name: null,
|
||||
profileUrl: null,
|
||||
type: null,
|
||||
company: null,
|
||||
status: "failed",
|
||||
repositoryAccess: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
scopes: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function accountId(login) {
|
||||
return `acct:${String(login).toLowerCase()}`;
|
||||
}
|
||||
|
||||
function candidateScore(candidate) {
|
||||
let score = SOURCE_PRIORITY[candidate.source] ?? 0;
|
||||
if (candidate.status === "ok") score += 1_000;
|
||||
if (candidate.repositoryAccess === true) score += 10_000;
|
||||
if (candidate.status === "failed") score -= 100_000;
|
||||
return score;
|
||||
}
|
||||
|
||||
function publicAccount(account) {
|
||||
const { token, ...safe } = account;
|
||||
return safe;
|
||||
}
|
||||
|
||||
export function invalidateAccounts() {
|
||||
cachedResolutions.clear();
|
||||
}
|
||||
|
||||
async function resolveAccountsUncached(preferredId, repository) {
|
||||
const probes = await Promise.all(
|
||||
(await detectCandidates()).map((candidate) => probeCandidate(candidate, repository)),
|
||||
);
|
||||
const groups = new Map();
|
||||
for (const probe of probes) {
|
||||
const keyForLogin = String(probe.login).toLowerCase();
|
||||
if (!groups.has(keyForLogin)) groups.set(keyForLogin, []);
|
||||
groups.get(keyForLogin).push(probe);
|
||||
}
|
||||
|
||||
const accounts = [];
|
||||
for (const [loginKey, candidates] of groups) {
|
||||
candidates.sort((left, right) => candidateScore(right) - candidateScore(left));
|
||||
const best = candidates[0];
|
||||
accounts.push({
|
||||
id: accountId(loginKey),
|
||||
login: best.login,
|
||||
avatarUrl: best.avatarUrl ?? candidates.find((item) => item.avatarUrl)?.avatarUrl ?? null,
|
||||
name: best.name ?? candidates.find((item) => item.name)?.name ?? null,
|
||||
profileUrl:
|
||||
best.profileUrl ?? candidates.find((item) => item.profileUrl)?.profileUrl ?? null,
|
||||
type: best.type ?? candidates.find((item) => item.type)?.type ?? null,
|
||||
company: best.company ?? candidates.find((item) => item.company)?.company ?? null,
|
||||
status: best.status,
|
||||
repositoryAccess: best.repositoryAccess,
|
||||
reason: best.reason ?? null,
|
||||
scopes: best.scopes,
|
||||
sourceKinds: [...new Set(candidates.map((item) => item.source))],
|
||||
sources: candidates.map((item, index) => ({
|
||||
label: SOURCE_LABEL[item.source] ?? item.source,
|
||||
source: item.source,
|
||||
hash: item.hash,
|
||||
status: item.status,
|
||||
repositoryAccess: item.repositoryAccess,
|
||||
reason: item.reason ?? null,
|
||||
scopes: item.scopes,
|
||||
chosen: index === 0,
|
||||
})),
|
||||
token: best.token,
|
||||
});
|
||||
}
|
||||
accounts.sort((left, right) => {
|
||||
const leftAccess = left.repositoryAccess === true ? 1 : 0;
|
||||
const rightAccess = right.repositoryAccess === true ? 1 : 0;
|
||||
if (leftAccess !== rightAccess) return rightAccess - leftAccess;
|
||||
if (left.status !== right.status) return left.status === "ok" ? -1 : 1;
|
||||
return left.login.localeCompare(right.login);
|
||||
});
|
||||
|
||||
let active = preferredId
|
||||
? accounts.find((account) => account.id === preferredId && account.status !== "failed")
|
||||
: null;
|
||||
active ??= accounts.find((account) => account.repositoryAccess === true);
|
||||
active ??= accounts.find((account) => account.status === "ok");
|
||||
active ??= null;
|
||||
|
||||
const value = {
|
||||
active: active ? publicAccount(active) : null,
|
||||
activeToken: active?.token ?? null,
|
||||
accounts: accounts.map(publicAccount),
|
||||
};
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function resolveAccounts({
|
||||
preferredId = null,
|
||||
repository = null,
|
||||
force = false,
|
||||
} = {}) {
|
||||
const key = `${preferredId ?? ""}|${repository ?? ""}`;
|
||||
let entry = cachedResolutions.get(key);
|
||||
if (!force && entry?.value && Date.now() - entry.at < CACHE_TTL_MS) {
|
||||
cachedResolutions.delete(key);
|
||||
cachedResolutions.set(key, entry);
|
||||
return entry.value;
|
||||
}
|
||||
if (entry?.promise && (!force || entry.forceRefresh)) return entry.promise;
|
||||
|
||||
entry ??= {
|
||||
at: 0,
|
||||
forceRefresh: false,
|
||||
promise: null,
|
||||
value: null,
|
||||
};
|
||||
const promise = resolveAccountsUncached(preferredId, repository).then(
|
||||
(value) => {
|
||||
if (cachedResolutions.get(key) === entry && entry.promise === promise) {
|
||||
entry.at = Date.now();
|
||||
entry.forceRefresh = false;
|
||||
entry.promise = null;
|
||||
entry.value = value;
|
||||
cachedResolutions.delete(key);
|
||||
cachedResolutions.set(key, entry);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
(error) => {
|
||||
if (cachedResolutions.get(key) === entry && entry.promise === promise) {
|
||||
entry.forceRefresh = false;
|
||||
entry.promise = null;
|
||||
if (!entry.value || Date.now() - entry.at >= CACHE_TTL_MS) {
|
||||
cachedResolutions.delete(key);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
entry.forceRefresh = force;
|
||||
entry.promise = promise;
|
||||
cachedResolutions.set(key, entry);
|
||||
while (cachedResolutions.size > RESOLUTION_CACHE_LIMIT) {
|
||||
const candidate = [...cachedResolutions].find(([, value]) => !value.promise);
|
||||
if (!candidate) break;
|
||||
cachedResolutions.delete(candidate[0]);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
.ap-default-term-ff {
|
||||
--term-font-family: "Cascadia Code", "Source Code Pro", Menlo, Consolas, "DejaVu Sans Mono", monospace, "Symbols Nerd Font";
|
||||
}
|
||||
div.ap-wrapper {
|
||||
outline: none;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
div.ap-wrapper .title-bar {
|
||||
display: none;
|
||||
top: -78px;
|
||||
transition: top 0.15s linear;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
box-sizing: content-box;
|
||||
font-size: 20px;
|
||||
line-height: 1em;
|
||||
padding: 15px;
|
||||
font-family: sans-serif;
|
||||
color: white;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
div.ap-wrapper .title-bar img {
|
||||
vertical-align: middle;
|
||||
height: 48px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
div.ap-wrapper .title-bar a {
|
||||
color: white;
|
||||
text-decoration: underline;
|
||||
}
|
||||
div.ap-wrapper .title-bar a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
div.ap-wrapper:fullscreen {
|
||||
background-color: #000;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
div.ap-wrapper:fullscreen .title-bar {
|
||||
display: initial;
|
||||
}
|
||||
div.ap-wrapper:fullscreen.hud .title-bar {
|
||||
top: 0;
|
||||
}
|
||||
div.ap-wrapper div.ap-player {
|
||||
text-align: left;
|
||||
display: inline-block;
|
||||
padding: 0px;
|
||||
position: relative;
|
||||
box-sizing: content-box;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
font-size: 15px;
|
||||
background-color: var(--term-color-background);
|
||||
}
|
||||
.ap-player {
|
||||
--term-color-foreground: #ffffff;
|
||||
--term-color-background: #000000;
|
||||
--term-color-0: var(--term-color-foreground);
|
||||
--term-color-1: var(--term-color-foreground);
|
||||
--term-color-2: var(--term-color-foreground);
|
||||
--term-color-3: var(--term-color-foreground);
|
||||
--term-color-4: var(--term-color-foreground);
|
||||
--term-color-5: var(--term-color-foreground);
|
||||
--term-color-6: var(--term-color-foreground);
|
||||
--term-color-7: var(--term-color-foreground);
|
||||
--term-color-8: var(--term-color-0);
|
||||
--term-color-9: var(--term-color-1);
|
||||
--term-color-10: var(--term-color-2);
|
||||
--term-color-11: var(--term-color-3);
|
||||
--term-color-12: var(--term-color-4);
|
||||
--term-color-13: var(--term-color-5);
|
||||
--term-color-14: var(--term-color-6);
|
||||
--term-color-15: var(--term-color-7);
|
||||
}
|
||||
div.ap-term {
|
||||
position: relative;
|
||||
font-family: var(--term-font-family);
|
||||
border-width: 0.75em;
|
||||
border-radius: 0;
|
||||
border-style: solid;
|
||||
border-color: var(--term-color-background);
|
||||
box-sizing: content-box;
|
||||
}
|
||||
div.ap-term canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
div.ap-term svg.ap-term-symbols {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
div.ap-term svg.ap-term-symbols use {
|
||||
color: var(--term-color-foreground);
|
||||
}
|
||||
div.ap-term svg.ap-term-symbols:not(.ap-blink) .ap-blink {
|
||||
opacity: 0;
|
||||
}
|
||||
div.ap-term pre.ap-term-text {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
box-sizing: content-box;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin: 0px;
|
||||
display: block;
|
||||
white-space: pre;
|
||||
word-wrap: normal;
|
||||
word-break: normal;
|
||||
cursor: text;
|
||||
color: var(--term-color-foreground);
|
||||
outline: none;
|
||||
line-height: var(--term-line-height);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-variant-ligatures: none;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
pre.ap-term-text .ap-line {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: var(--term-line-height);
|
||||
position: absolute;
|
||||
top: calc(100% * var(--row) / var(--term-rows));
|
||||
letter-spacing: normal;
|
||||
overflow: hidden;
|
||||
}
|
||||
pre.ap-term-text .ap-line span {
|
||||
position: absolute;
|
||||
left: calc(100% * var(--offset) / var(--term-cols));
|
||||
padding: 0;
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
}
|
||||
pre.ap-term-text:not(.ap-blink) .ap-line .ap-blink {
|
||||
color: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
pre.ap-term-text .ap-bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
pre.ap-term-text .ap-faint {
|
||||
opacity: 0.5;
|
||||
}
|
||||
pre.ap-term-text .ap-underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
pre.ap-term-text .ap-italic {
|
||||
font-style: italic;
|
||||
}
|
||||
pre.ap-term-text .ap-strike {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.ap-line span {
|
||||
color: var(--term-color-foreground);
|
||||
}
|
||||
div.ap-player div.ap-control-bar {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
color: var(--term-color-foreground);
|
||||
box-sizing: content-box;
|
||||
line-height: 1;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s linear;
|
||||
user-select: none;
|
||||
border-top: 2px solid color-mix(in oklab, var(--term-color-background) 80%, var(--term-color-foreground));
|
||||
z-index: 30;
|
||||
}
|
||||
div.ap-player div.ap-control-bar * {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
div.ap-control-bar svg.ap-icon path {
|
||||
fill: var(--term-color-foreground);
|
||||
}
|
||||
div.ap-control-bar button.ap-button {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
div.ap-control-bar button.ap-playback-button {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
padding: 10px;
|
||||
margin: 0 0 0 2px;
|
||||
}
|
||||
div.ap-control-bar button.ap-playback-button svg {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}
|
||||
div.ap-control-bar span.ap-timer {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
min-width: 50px;
|
||||
margin: 0 10px;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
line-height: 100%;
|
||||
cursor: default;
|
||||
}
|
||||
div.ap-control-bar span.ap-timer span {
|
||||
font-family: var(--term-font-family);
|
||||
font-size: inherit;
|
||||
font-weight: 600;
|
||||
margin: auto;
|
||||
}
|
||||
div.ap-control-bar span.ap-timer .ap-time-remaining {
|
||||
display: none;
|
||||
}
|
||||
div.ap-control-bar span.ap-timer:hover .ap-time-elapsed {
|
||||
display: none;
|
||||
}
|
||||
div.ap-control-bar span.ap-timer:hover .ap-time-remaining {
|
||||
display: flex;
|
||||
}
|
||||
div.ap-control-bar .ap-progressbar {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
padding: 0 10px;
|
||||
}
|
||||
div.ap-control-bar .ap-progressbar .ap-bar {
|
||||
display: block;
|
||||
position: relative;
|
||||
cursor: default;
|
||||
height: 100%;
|
||||
font-size: 0;
|
||||
}
|
||||
div.ap-control-bar .ap-progressbar .ap-bar .ap-gutter {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
}
|
||||
div.ap-control-bar .ap-progressbar .ap-bar .ap-gutter-empty {
|
||||
background-color: color-mix(in oklab, var(--term-color-foreground) 20%, var(--term-color-background));
|
||||
}
|
||||
div.ap-control-bar .ap-progressbar .ap-bar .ap-gutter-full {
|
||||
width: 100%;
|
||||
transform-origin: left center;
|
||||
background-color: var(--term-color-foreground);
|
||||
border-radius: 3px;
|
||||
}
|
||||
div.ap-control-bar.ap-seekable .ap-progressbar .ap-bar {
|
||||
cursor: pointer;
|
||||
}
|
||||
div.ap-control-bar button.ap-fullscreen-button {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
padding: 9px;
|
||||
margin: 0 2px 0 4px;
|
||||
}
|
||||
div.ap-control-bar button.ap-fullscreen-button svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
div.ap-control-bar button.ap-fullscreen-button svg.ap-icon-fullscreen-on {
|
||||
display: inline;
|
||||
}
|
||||
div.ap-control-bar button.ap-fullscreen-button svg.ap-icon-fullscreen-off {
|
||||
display: none;
|
||||
}
|
||||
div.ap-control-bar button.ap-fullscreen-button .ap-tooltip {
|
||||
right: 5px;
|
||||
left: initial;
|
||||
transform: none;
|
||||
}
|
||||
div.ap-control-bar button.ap-kbd-button {
|
||||
height: 14px;
|
||||
padding: 9px;
|
||||
margin: 0 0 0 4px;
|
||||
}
|
||||
div.ap-control-bar button.ap-kbd-button svg {
|
||||
width: 26px;
|
||||
height: 14px;
|
||||
}
|
||||
div.ap-control-bar button.ap-kbd-button .ap-tooltip {
|
||||
right: 5px;
|
||||
left: initial;
|
||||
transform: none;
|
||||
}
|
||||
div.ap-control-bar button.ap-speaker-button {
|
||||
width: 19px;
|
||||
padding: 6px 9px;
|
||||
margin: 0 0 0 4px;
|
||||
position: relative;
|
||||
}
|
||||
div.ap-control-bar button.ap-speaker-button svg {
|
||||
width: 19px;
|
||||
}
|
||||
div.ap-control-bar button.ap-speaker-button .ap-tooltip {
|
||||
left: -50%;
|
||||
transform: none;
|
||||
}
|
||||
div.ap-wrapper.ap-hud .ap-control-bar {
|
||||
opacity: 1;
|
||||
}
|
||||
div.ap-wrapper:fullscreen button.ap-fullscreen-button svg.ap-icon-fullscreen-on {
|
||||
display: none;
|
||||
}
|
||||
div.ap-wrapper:fullscreen button.ap-fullscreen-button svg.ap-icon-fullscreen-off {
|
||||
display: inline;
|
||||
}
|
||||
span.ap-progressbar span.ap-marker-container {
|
||||
display: block;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 21px;
|
||||
position: absolute;
|
||||
margin-left: -10px;
|
||||
}
|
||||
span.ap-marker-container span.ap-marker {
|
||||
display: block;
|
||||
top: 13px;
|
||||
bottom: 12px;
|
||||
left: 7px;
|
||||
right: 7px;
|
||||
background-color: color-mix(in oklab, var(--term-color-foreground) 33%, var(--term-color-background));
|
||||
position: absolute;
|
||||
transition: top 0.1s, bottom 0.1s, left 0.1s, right 0.1s, background-color 0.1s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
span.ap-marker-container span.ap-marker.ap-marker-past {
|
||||
background-color: var(--term-color-foreground);
|
||||
}
|
||||
span.ap-marker-container span.ap-marker:hover,
|
||||
span.ap-marker-container:hover span.ap-marker {
|
||||
background-color: var(--term-color-foreground);
|
||||
top: 11px;
|
||||
bottom: 10px;
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
}
|
||||
.ap-tooltip-container span.ap-tooltip {
|
||||
visibility: hidden;
|
||||
background-color: var(--term-color-foreground);
|
||||
color: var(--term-color-background);
|
||||
font-family: var(--term-font-family);
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
padding: 0 0.5em;
|
||||
border-radius: 4px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
white-space: nowrap;
|
||||
/* Prevents the text from wrapping and makes sure the tooltip width adapts to the text length */
|
||||
font-size: 13px;
|
||||
line-height: 2em;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.ap-tooltip-container:hover span.ap-tooltip {
|
||||
visibility: visible;
|
||||
}
|
||||
.ap-player .ap-overlay {
|
||||
z-index: 10;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.ap-player .ap-overlay-start {
|
||||
cursor: pointer;
|
||||
}
|
||||
.ap-player .ap-overlay-start .ap-play-button {
|
||||
font-size: 0px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
text-align: center;
|
||||
color: white;
|
||||
height: 80px;
|
||||
max-height: 66%;
|
||||
margin: auto;
|
||||
}
|
||||
.ap-player .ap-overlay-start .ap-play-button div {
|
||||
height: 100%;
|
||||
}
|
||||
.ap-player .ap-overlay-start .ap-play-button div span {
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.ap-player .ap-overlay-start .ap-play-button div span svg {
|
||||
height: 100%;
|
||||
display: inline-block;
|
||||
}
|
||||
.ap-player .ap-overlay-start .ap-play-button svg {
|
||||
filter: drop-shadow(0px 0px 5px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
.ap-player .ap-overlay-loading .ap-loader {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
border: 10px solid;
|
||||
border-color: rgba(255, 255, 255, 0.3) rgba(255, 255, 255, 0.5) rgba(255, 255, 255, 0.7) #ffffff;
|
||||
border-color: color-mix(in srgb, var(--term-color-foreground) 30%, var(--term-color-background)) color-mix(in srgb, var(--term-color-foreground) 50%, var(--term-color-background)) color-mix(in srgb, var(--term-color-foreground) 70%, var(--term-color-background)) color-mix(in srgb, var(--term-color-foreground) 100%, var(--term-color-background));
|
||||
box-sizing: border-box;
|
||||
animation: ap-loader-rotation 1s linear infinite;
|
||||
}
|
||||
.ap-player .ap-overlay-info {
|
||||
background-color: var(--term-color-background);
|
||||
}
|
||||
.ap-player .ap-overlay-info span {
|
||||
font-family: var(--term-font-family);
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: var(--term-color-background);
|
||||
background-color: var(--term-color-foreground);
|
||||
padding: 0.5em 0.75em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.ap-player .ap-overlay-keystrokes {
|
||||
position: absolute;
|
||||
top: auto;
|
||||
left: auto;
|
||||
right: 20px;
|
||||
bottom: var(--ap-keystrokes-bottom);
|
||||
z-index: 20;
|
||||
width: auto;
|
||||
height: auto;
|
||||
font-size: 18px;
|
||||
text-align: right;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ap-player .ap-overlay-keystrokes .ap-keystroke-pill {
|
||||
font-family: var(--term-font-family);
|
||||
font-size: inherit;
|
||||
opacity: 1;
|
||||
transition: opacity 80ms ease;
|
||||
}
|
||||
.ap-player .ap-overlay-keystrokes .ap-keystroke-pill kbd {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
min-width: 1.2em;
|
||||
color: var(--term-color-background);
|
||||
background-color: var(--term-color-foreground);
|
||||
padding: 0.3em 0.5em;
|
||||
border-radius: 0.2em;
|
||||
font-family: inherit;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
margin: 0;
|
||||
}
|
||||
.ap-player .ap-overlay-keystrokes .ap-keystroke-pill.fading {
|
||||
opacity: 0;
|
||||
transition: opacity 700ms ease;
|
||||
}
|
||||
.ap-player .ap-overlay-help {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
container-type: inline-size;
|
||||
}
|
||||
.ap-player .ap-overlay-help > div {
|
||||
font-family: var(--term-font-family);
|
||||
max-width: 85%;
|
||||
max-height: 85%;
|
||||
font-size: 18px;
|
||||
color: var(--term-color-foreground);
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.ap-player .ap-overlay-help > div div {
|
||||
padding: calc(min(4cqw, 40px));
|
||||
font-size: calc(min(1.9cqw, 18px));
|
||||
background-color: var(--term-color-background);
|
||||
border: 1px solid color-mix(in oklab, var(--term-color-background) 90%, var(--term-color-foreground));
|
||||
border-radius: 6px;
|
||||
}
|
||||
.ap-player .ap-overlay-help > div div p {
|
||||
font-weight: bold;
|
||||
margin: 0 0 2em 0;
|
||||
}
|
||||
.ap-player .ap-overlay-help > div div ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
.ap-player .ap-overlay-help > div div ul li {
|
||||
margin: 0 0 0.75em 0;
|
||||
}
|
||||
.ap-player .ap-overlay-help > div div kbd {
|
||||
color: var(--term-color-background);
|
||||
background-color: var(--term-color-foreground);
|
||||
padding: 0.2em 0.5em;
|
||||
border-radius: 0.2em;
|
||||
font-family: inherit;
|
||||
font-size: 0.85em;
|
||||
border: none;
|
||||
margin: 0;
|
||||
}
|
||||
.ap-player .ap-overlay-error span {
|
||||
font-size: 8em;
|
||||
}
|
||||
.ap-player .slide-enter-active {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.ap-player .slide-enter-active.ap-was-playing {
|
||||
transition: top 0.2s ease-out, opacity 0.2s;
|
||||
}
|
||||
.ap-player .slide-exit-active {
|
||||
transition: top 0.2s ease-in, opacity 0.2s;
|
||||
}
|
||||
.ap-player .slide-enter {
|
||||
top: -50%;
|
||||
opacity: 0;
|
||||
}
|
||||
.ap-player .slide-enter-to {
|
||||
top: 0%;
|
||||
}
|
||||
.ap-player .slide-enter,
|
||||
.ap-player .slide-enter-to,
|
||||
.ap-player .slide-exit,
|
||||
.ap-player .slide-exit-to {
|
||||
bottom: auto;
|
||||
height: 100%;
|
||||
}
|
||||
.ap-player .slide-exit {
|
||||
top: 0%;
|
||||
}
|
||||
.ap-player .slide-exit-to {
|
||||
top: -50%;
|
||||
opacity: 0;
|
||||
}
|
||||
@keyframes ap-loader-rotation {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.asciinema-player-theme-asciinema {
|
||||
--term-color-foreground: #cccccc;
|
||||
--term-color-background: #121314;
|
||||
--term-color-0: #000000;
|
||||
--term-color-1: #dd3c69;
|
||||
--term-color-2: #4ebf22;
|
||||
--term-color-3: #ddaf3c;
|
||||
--term-color-4: #26b0d7;
|
||||
--term-color-5: #b954e1;
|
||||
--term-color-6: #54e1b9;
|
||||
--term-color-7: #d9d9d9;
|
||||
--term-color-8: #4d4d4d;
|
||||
--term-color-9: #dd3c69;
|
||||
--term-color-10: #4ebf22;
|
||||
--term-color-11: #ddaf3c;
|
||||
--term-color-12: #26b0d7;
|
||||
--term-color-13: #b954e1;
|
||||
--term-color-14: #54e1b9;
|
||||
--term-color-15: #ffffff;
|
||||
}
|
||||
/*
|
||||
Based on Dracula: https://draculatheme.com
|
||||
*/
|
||||
.asciinema-player-theme-dracula {
|
||||
--term-color-foreground: #f8f8f2;
|
||||
--term-color-background: #282a36;
|
||||
--term-color-0: #21222c;
|
||||
--term-color-1: #ff5555;
|
||||
--term-color-2: #50fa7b;
|
||||
--term-color-3: #f1fa8c;
|
||||
--term-color-4: #bd93f9;
|
||||
--term-color-5: #ff79c6;
|
||||
--term-color-6: #8be9fd;
|
||||
--term-color-7: #f8f8f2;
|
||||
--term-color-8: #6272a4;
|
||||
--term-color-9: #ff6e6e;
|
||||
--term-color-10: #69ff94;
|
||||
--term-color-11: #ffffa5;
|
||||
--term-color-12: #d6acff;
|
||||
--term-color-13: #ff92df;
|
||||
--term-color-14: #a4ffff;
|
||||
--term-color-15: #ffffff;
|
||||
}
|
||||
/* Based on Monokai from base16 collection - https://github.com/chriskempson/base16 */
|
||||
.asciinema-player-theme-monokai {
|
||||
--term-color-foreground: #f8f8f2;
|
||||
--term-color-background: #272822;
|
||||
--term-color-0: #272822;
|
||||
--term-color-1: #f92672;
|
||||
--term-color-2: #a6e22e;
|
||||
--term-color-3: #f4bf75;
|
||||
--term-color-4: #66d9ef;
|
||||
--term-color-5: #ae81ff;
|
||||
--term-color-6: #a1efe4;
|
||||
--term-color-7: #f8f8f2;
|
||||
--term-color-8: #75715e;
|
||||
--term-color-15: #f9f8f5;
|
||||
}
|
||||
/*
|
||||
Based on Nord: https://github.com/arcticicestudio/nord
|
||||
Via: https://github.com/neilotoole/asciinema-theme-nord
|
||||
*/
|
||||
.asciinema-player-theme-nord {
|
||||
--term-color-foreground: #eceff4;
|
||||
--term-color-background: #2e3440;
|
||||
--term-color-0: #3b4252;
|
||||
--term-color-1: #bf616a;
|
||||
--term-color-2: #a3be8c;
|
||||
--term-color-3: #ebcb8b;
|
||||
--term-color-4: #81a1c1;
|
||||
--term-color-5: #b48ead;
|
||||
--term-color-6: #88c0d0;
|
||||
--term-color-7: #eceff4;
|
||||
}
|
||||
.asciinema-player-theme-seti {
|
||||
--term-color-foreground: #cacecd;
|
||||
--term-color-background: #111213;
|
||||
--term-color-0: #323232;
|
||||
--term-color-1: #c22832;
|
||||
--term-color-2: #8ec43d;
|
||||
--term-color-3: #e0c64f;
|
||||
--term-color-4: #43a5d5;
|
||||
--term-color-5: #8b57b5;
|
||||
--term-color-6: #8ec43d;
|
||||
--term-color-7: #eeeeee;
|
||||
--term-color-15: #ffffff;
|
||||
}
|
||||
/*
|
||||
Based on Solarized Dark: https://ethanschoonover.com/solarized/
|
||||
*/
|
||||
.asciinema-player-theme-solarized-dark {
|
||||
--term-color-foreground: #839496;
|
||||
--term-color-background: #002b36;
|
||||
--term-color-0: #073642;
|
||||
--term-color-1: #dc322f;
|
||||
--term-color-2: #859900;
|
||||
--term-color-3: #b58900;
|
||||
--term-color-4: #268bd2;
|
||||
--term-color-5: #d33682;
|
||||
--term-color-6: #2aa198;
|
||||
--term-color-7: #eee8d5;
|
||||
--term-color-8: #002b36;
|
||||
--term-color-9: #cb4b16;
|
||||
--term-color-10: #586e75;
|
||||
--term-color-11: #657b83;
|
||||
--term-color-12: #839496;
|
||||
--term-color-13: #6c71c4;
|
||||
--term-color-14: #93a1a1;
|
||||
--term-color-15: #fdf6e3;
|
||||
}
|
||||
/*
|
||||
Based on Solarized Light: https://ethanschoonover.com/solarized/
|
||||
*/
|
||||
.asciinema-player-theme-solarized-light {
|
||||
--term-color-foreground: #657b83;
|
||||
--term-color-background: #fdf6e3;
|
||||
--term-color-0: #073642;
|
||||
--term-color-1: #dc322f;
|
||||
--term-color-2: #859900;
|
||||
--term-color-3: #b58900;
|
||||
--term-color-4: #268bd2;
|
||||
--term-color-5: #d33682;
|
||||
--term-color-6: #2aa198;
|
||||
--term-color-7: #eee8d5;
|
||||
--term-color-8: #002b36;
|
||||
--term-color-9: #cb4b16;
|
||||
--term-color-10: #586e75;
|
||||
--term-color-11: #657c83;
|
||||
--term-color-12: #839496;
|
||||
--term-color-13: #6c71c4;
|
||||
--term-color-14: #93a1a1;
|
||||
--term-color-15: #fdf6e3;
|
||||
}
|
||||
.asciinema-player-theme-solarized-light .ap-overlay-start .ap-play-button svg .ap-play-btn-fill {
|
||||
fill: var(--term-color-1);
|
||||
}
|
||||
.asciinema-player-theme-solarized-light .ap-overlay-start .ap-play-button svg .ap-play-btn-stroke {
|
||||
stroke: var(--term-color-1);
|
||||
}
|
||||
/*
|
||||
Based on Tango: https://en.wikipedia.org/wiki/Tango_Desktop_Project
|
||||
*/
|
||||
.asciinema-player-theme-tango {
|
||||
--term-color-foreground: #cccccc;
|
||||
--term-color-background: #121314;
|
||||
--term-color-0: #000000;
|
||||
--term-color-1: #cc0000;
|
||||
--term-color-2: #4e9a06;
|
||||
--term-color-3: #c4a000;
|
||||
--term-color-4: #3465a4;
|
||||
--term-color-5: #75507b;
|
||||
--term-color-6: #06989a;
|
||||
--term-color-7: #d3d7cf;
|
||||
--term-color-8: #555753;
|
||||
--term-color-9: #ef2929;
|
||||
--term-color-10: #8ae234;
|
||||
--term-color-11: #fce94f;
|
||||
--term-color-12: #729fcf;
|
||||
--term-color-13: #ad7fa8;
|
||||
--term-color-14: #34e2e2;
|
||||
--term-color-15: #eeeeec;
|
||||
}
|
||||
/*
|
||||
Based on gruvbox: https://github.com/morhetz/gruvbox
|
||||
*/
|
||||
.asciinema-player-theme-gruvbox-dark {
|
||||
--term-color-foreground: #fbf1c7;
|
||||
--term-color-background: #282828;
|
||||
--term-color-0: #282828;
|
||||
--term-color-1: #cc241d;
|
||||
--term-color-2: #98971a;
|
||||
--term-color-3: #d79921;
|
||||
--term-color-4: #458588;
|
||||
--term-color-5: #b16286;
|
||||
--term-color-6: #689d6a;
|
||||
--term-color-7: #a89984;
|
||||
--term-color-8: #7c6f65;
|
||||
--term-color-9: #fb4934;
|
||||
--term-color-10: #b8bb26;
|
||||
--term-color-11: #fabd2f;
|
||||
--term-color-12: #83a598;
|
||||
--term-color-13: #d3869b;
|
||||
--term-color-14: #8ec07c;
|
||||
--term-color-15: #fbf1c7;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v1.5A1.75 1.75 0 0 1 14.25 6H1.75A1.75 1.75 0 0 1 0 4.25ZM1.75 7a.75.75 0 0 1 .75.75v5.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-5.5a.75.75 0 0 1 1.5 0v5.5A1.75 1.75 0 0 1 13.25 15H2.75A1.75 1.75 0 0 1 1 13.25v-5.5A.75.75 0 0 1 1.75 7Zm0-4.5a.25.25 0 0 0-.25.25v1.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-1.5a.25.25 0 0 0-.25-.25ZM6.25 8h3.5a.75.75 0 0 1 0 1.5h-3.5a.75.75 0 0 1 0-1.5Z"/></svg>
|
||||
|
After Width: | Height: | Size: 560 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25ZM14.5 6h-13v7.25c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25Zm-6-3.5v2h6V2.75a.25.25 0 0 0-.25-.25ZM5 2.5v2h2v-2Zm-3.25 0a.25.25 0 0 0-.25.25V4.5h2v-2Z"/></svg>
|
||||
|
After Width: | Height: | Size: 373 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm1.5 0a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm10.28-1.72-4.5 4.5a.75.75 0 0 1-1.06 0l-2-2a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018l1.47 1.47 3.97-3.97a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z"/></svg>
|
||||
|
After Width: | Height: | Size: 346 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 264 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 244 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M9.78 12.78a.75.75 0 0 1-1.06 0L4.47 8.53a.75.75 0 0 1 0-1.06l4.25-4.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L6.06 8l3.72 3.72a.75.75 0 0 1 0 1.06Z"/></svg>
|
||||
|
After Width: | Height: | Size: 260 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z"/></svg>
|
||||
|
After Width: | Height: | Size: 261 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5a.75.75 0 0 1 1.5 0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 281 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="m11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L13.94 8l-3.72-3.72a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215Zm-6.56 0a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.06 8l3.72 3.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L.47 8.53a.75.75 0 0 1 0-1.06Z"/></svg>
|
||||
|
After Width: | Height: | Size: 427 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/></svg>
|
||||
|
After Width: | Height: | Size: 435 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"/><path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969Z"/></svg>
|
||||
|
After Width: | Height: | Size: 427 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.671-.992-4.933-2.078C1.797 10.83.88 9.576.43 8.898a1.62 1.62 0 0 1 0-1.798c.45-.677 1.367-1.931 2.637-3.022C4.33 2.992 6.019 2 8 2ZM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.825.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z"/></svg>
|
||||
|
After Width: | Height: | Size: 749 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M4 1.75C4 .784 4.784 0 5.75 0h5.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v8.586A1.75 1.75 0 0 1 14.25 15h-9a.75.75 0 0 1 0-1.5h9a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 10 4.25V1.5H5.75a.25.25 0 0 0-.25.25v2a.75.75 0 0 1-1.5 0Zm-4 6C0 6.784.784 6 1.75 6h1.5C4.216 6 5 6.784 5 7.75v2.5A1.75 1.75 0 0 1 3.25 12h-1.5A1.75 1.75 0 0 1 0 10.25ZM6.75 6h1.5a.75.75 0 0 1 .75.75v3.75h.75a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1 0-1.5h.75v-3h-.75a.75.75 0 0 1 0-1.5Zm-5 1.5a.25.25 0 0 0-.25.25v2.5c0 .138.112.25.25.25h1.5a.25.25 0 0 0 .25-.25v-2.5a.25.25 0 0 0-.25-.25Zm9.75-5.938V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></svg>
|
||||
|
After Width: | Height: | Size: 763 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M4 1.75C4 .784 4.784 0 5.75 0h5.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v8.586A1.75 1.75 0 0 1 14.25 15h-9a.75.75 0 0 1 0-1.5h9a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 10 4.25V1.5H5.75a.25.25 0 0 0-.25.25v2.5a.75.75 0 0 1-1.5 0Zm1.72 4.97a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.47-1.47-1.47-1.47a.75.75 0 0 1 0-1.06ZM3.28 7.78 1.81 9.25l1.47 1.47a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-2-2a.75.75 0 0 1 0-1.06l2-2a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Zm8.22-6.218V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></svg>
|
||||
|
After Width: | Height: | Size: 748 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M0 2.75C0 1.784.784 1 1.75 1H5c.55 0 1.07.26 1.4.7l.9 1.2a.25.25 0 0 0 .2.1h6.75c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H7.5c-.55 0-1.07-.26-1.4-.7l-.9-1.2a.25.25 0 0 0-.2-.1Z"/></svg>
|
||||
|
After Width: | Height: | Size: 427 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M16 13.25A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25V2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75ZM1.75 2.5a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h.94l.03-.03 6.077-6.078a1.75 1.75 0 0 1 2.412-.06L14.5 10.31V2.75a.25.25 0 0 0-.25-.25Zm12.5 11a.25.25 0 0 0 .25-.25v-.917l-4.298-3.889a.25.25 0 0 0-.344.009L4.81 13.5ZM7 6a2 2 0 1 1-3.999.001A2 2 0 0 1 7 6ZM5.5 6a.5.5 0 1 0-1 0 .5.5 0 0 0 1 0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 521 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M3.5 1.75v11.5c0 .09.048.173.126.217a.75.75 0 0 1-.752 1.298A1.748 1.748 0 0 1 2 13.25V1.75C2 .784 2.784 0 3.75 0h5.586c.464 0 .909.185 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v8.586A1.75 1.75 0 0 1 12.25 15h-.5a.75.75 0 0 1 0-1.5h.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177L9.513 1.573a.25.25 0 0 0-.177-.073H7.25a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5h-3a.25.25 0 0 0-.25.25Zm3.75 8.75h.5c.966 0 1.75.784 1.75 1.75v3a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1-.75-.75v-3c0-.966.784-1.75 1.75-1.75ZM6 5.25a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 6 5.25Zm.75 2.25h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5ZM8 6.75A.75.75 0 0 1 8.75 6h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 8 6.75ZM8.75 3h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5ZM8 9.75A.75.75 0 0 1 8.75 9h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 8 9.75Zm-1 2.5v2.25h1v-2.25a.25.25 0 0 0-.25-.25h-.5a.25.25 0 0 0-.25.25Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1012 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></svg>
|
||||
|
After Width: | Height: | Size: 451 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M.75 3h14.5a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1 0-1.5ZM3 7.75A.75.75 0 0 1 3.75 7h8.5a.75.75 0 0 1 0 1.5h-8.5A.75.75 0 0 1 3 7.75Zm3 4a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"/></svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.53.01 7.764 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9.5 8a1.5 1.5 0 1 0-3.001.001A1.5 1.5 0 0 0 9.5 8Z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 513 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM1.5 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm13 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/></svg>
|
||||
|
After Width: | Height: | Size: 227 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z"/></svg>
|
||||
|
After Width: | Height: | Size: 495 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M6.766 11.328c-2.063-.25-3.516-1.734-3.516-3.656 0-.781.281-1.625.75-2.188-.203-.515-.172-1.609.063-2.062.625-.078 1.468.25 1.968.703.594-.187 1.219-.281 1.985-.281.765 0 1.39.094 1.953.265.484-.437 1.344-.765 1.969-.687.218.422.25 1.515.046 2.047.5.593.766 1.39.766 2.203 0 1.922-1.453 3.375-3.547 3.64.531.344.89 1.094.89 1.954v1.625c0 .468.391.734.86.547C13.781 14.359 16 11.53 16 8.03 16 3.61 12.406 0 7.984 0 3.563 0 0 3.61 0 8.031a7.88 7.88 0 0 0 5.172 7.422c.422.156.828-.125.828-.547v-1.25c-.219.094-.5.156-.75.156-1.031 0-1.64-.562-2.078-1.609-.172-.422-.36-.672-.719-.719-.187-.015-.25-.093-.25-.187 0-.188.313-.328.625-.328.453 0 .844.281 1.25.86.313.452.64.655 1.031.655s.641-.14 1-.5c.266-.265.47-.5.657-.656"/></svg>
|
||||
|
After Width: | Height: | Size: 822 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M14.85 3c.63 0 1.15.52 1.14 1.15v7.7c0 .63-.51 1.15-1.15 1.15H1.15C.52 13 0 12.48 0 11.84V4.15C0 3.52.52 3 1.15 3ZM9 11V5H7L5.5 7 4 5H2v6h2V8l1.5 1.92L7 8v3Zm2.99.5L14.5 8H13V5h-2v3H9.5Z"/></svg>
|
||||
|
After Width: | Height: | Size: 287 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="m8.878.392 5.25 3.045c.54.314.872.89.872 1.514v6.098a1.75 1.75 0 0 1-.872 1.514l-5.25 3.045a1.75 1.75 0 0 1-1.756 0l-5.25-3.045A1.75 1.75 0 0 1 1 11.049V4.951c0-.624.332-1.201.872-1.514L7.122.392a1.75 1.75 0 0 1 1.756 0ZM7.875 1.69l-4.63 2.685L8 7.133l4.755-2.758-4.63-2.685a.248.248 0 0 0-.25 0ZM2.5 5.677v5.372c0 .09.047.171.125.216l4.625 2.683V8.432Zm6.25 8.271 4.625-2.683a.25.25 0 0 0 .125-.216V5.677L8.75 8.432Z"/></svg>
|
||||
|
After Width: | Height: | Size: 518 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M10.561 8.073a6.005 6.005 0 0 1 3.432 5.142.75.75 0 1 1-1.498.07 4.5 4.5 0 0 0-8.99 0 .75.75 0 0 1-1.498-.07 6.004 6.004 0 0 1 3.431-5.142 3.999 3.999 0 1 1 5.123 0ZM10.5 5a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 310 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="m1.655.595 13.75 13.75q.22.219.22.53 0 .311-.22.53-.219.22-.53.22-.311 0-.53-.22L.595 1.655q-.22-.219-.22-.53 0-.311.22-.53.219-.22.53-.22.311 0 .53.22ZM.72 14.22l4.5-4.5q.219-.22.53-.22.311 0 .53.22.22.219.22.53 0 .311-.22.53l-4.5 4.5q-.219.22-.53.22-.311 0-.53-.22-.22-.219-.22-.53 0-.311.22-.53Z"/><path d="m5.424 6.146-1.759.419q-.143.034-.183.175-.04.141.064.245l5.469 5.469q.104.104.245.064.141-.04.175-.183l.359-1.509q.072-.302.337-.465.264-.163.567-.091.302.072.465.337.162.264.09.567l-.359 1.509q-.238.999-1.226 1.278-.988.28-1.714-.446L2.485 8.046q-.726-.726-.446-1.714.279-.988 1.278-1.226l1.759-.419q.303-.072.567.091.265.163.337.465.072.302-.091.567-.163.264-.465.336ZM7.47 3.47q.155-.156.247-.355l.751-1.627Q8.851.659 9.75.498q.899-.16 1.544.486l3.722 3.722q.646.645.486 1.544-.161.899-.99 1.282l-1.627.751q-.199.092-.355.247-.219.22-.53.22-.311 0-.53-.22-.22-.219-.22-.53 0-.311.22-.53.344-.345.787-.549l1.627-.751q.118-.055.141-.183.023-.128-.069-.221l-3.722-3.722q-.092-.092-.221-.069-.128.023-.183.141l-.751 1.627q-.204.443-.549.787-.219.22-.53.22-.311 0-.53-.22-.22-.219-.22-.53 0-.311.22-.53Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="m11.294.984 3.722 3.722a1.75 1.75 0 0 1-.504 2.826l-1.327.613a3.089 3.089 0 0 0-1.707 2.084l-.584 2.454c-.317 1.332-1.972 1.8-2.94.832L5.75 11.311 1.78 15.28a.749.749 0 1 1-1.06-1.06l3.969-3.97-2.204-2.204c-.968-.968-.5-2.623.832-2.94l2.454-.584a3.08 3.08 0 0 0 2.084-1.707l.613-1.327a1.75 1.75 0 0 1 2.826-.504ZM6.283 9.723l2.732 2.731a.25.25 0 0 0 .42-.119l.584-2.454a4.586 4.586 0 0 1 2.537-3.098l1.328-.613a.25.25 0 0 0 .072-.404l-3.722-3.722a.25.25 0 0 0-.404.072l-.613 1.328a4.584 4.584 0 0 1-3.098 2.537l-2.454.584a.25.25 0 0 0-.119.42l2.731 2.732Z"/></svg>
|
||||
|
After Width: | Height: | Size: 657 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm4.879-2.773 4.264 2.559a.25.25 0 0 1 0 .428l-4.264 2.559A.25.25 0 0 1 6 10.559V5.442a.25.25 0 0 1 .379-.215Z"/></svg>
|
||||
|
After Width: | Height: | Size: 289 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z"/></svg>
|
||||
|
After Width: | Height: | Size: 474 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
|
||||
|
After Width: | Height: | Size: 277 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M6.823 7.823a.25.25 0 0 1 0 .354l-2.396 2.396A.25.25 0 0 1 4 10.396V5.604a.25.25 0 0 1 .427-.177Z"/><path d="M1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25V1.75C0 .784.784 0 1.75 0ZM1.5 1.75v12.5c0 .138.112.25.25.25H9.5v-13H1.75a.25.25 0 0 0-.25.25ZM11 14.5h3.25a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25H11Z"/></svg>
|
||||
|
After Width: | Height: | Size: 465 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="m4.177 7.823 2.396-2.396A.25.25 0 0 1 7 5.604v4.792a.25.25 0 0 1-.427.177L4.177 8.177a.25.25 0 0 1 0-.354Z"/><path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25H9.5v-13Zm12.5 13a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25H11v13Z"/></svg>
|
||||
|
After Width: | Height: | Size: 462 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z"/></svg>
|
||||
|
After Width: | Height: | Size: 350 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Zm0 2.445L6.615 5.5a.75.75 0 0 1-.564.41l-3.097.45 2.24 2.184a.75.75 0 0 1 .216.664l-.528 3.084 2.769-1.456a.75.75 0 0 1 .698 0l2.77 1.456-.53-3.084a.75.75 0 0 1 .216-.664l2.24-2.183-3.096-.45a.75.75 0 0 1-.564-.41L8 2.694Z"/></svg>
|
||||
|
After Width: | Height: | Size: 572 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z"/></svg>
|
||||
|
After Width: | Height: | Size: 518 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25ZM7.25 8a.749.749 0 0 1-.22.53l-2.25 2.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L5.44 8 3.72 6.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.25 2.25c.141.14.22.331.22.53Zm1.5 1.5h3a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1 0-1.5Z"/></svg>
|
||||
|
After Width: | Height: | Size: 567 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.149l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z"/></svg>
|
||||
|
After Width: | Height: | Size: 488 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M0 1.75C0 .784.784 0 1.75 0h3.5C6.216 0 7 .784 7 1.75v3.5A1.75 1.75 0 0 1 5.25 7H4v4a1 1 0 0 0 1 1h4v-1.25C9 9.784 9.784 9 10.75 9h3.5c.966 0 1.75.784 1.75 1.75v3.5A1.75 1.75 0 0 1 14.25 16h-3.5A1.75 1.75 0 0 1 9 14.25v-.75H5A2.5 2.5 0 0 1 2.5 11V7h-.75A1.75 1.75 0 0 1 0 5.25Zm1.75-.25a.25.25 0 0 0-.25.25v3.5c0 .138.112.25.25.25h3.5a.25.25 0 0 0 .25-.25v-3.5a.25.25 0 0 0-.25-.25Zm9 9a.25.25 0 0 0-.25.25v3.5c0 .138.112.25.25.25h3.5a.25.25 0 0 0 .25-.25v-3.5a.25.25 0 0 0-.25-.25Z"/></svg>
|
||||
|
After Width: | Height: | Size: 583 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M2.344 2.343h-.001a8 8 0 0 1 11.314 11.314A8.002 8.002 0 0 1 .234 10.089a8 8 0 0 1 2.11-7.746Zm1.06 10.253a6.5 6.5 0 1 0 9.108-9.275 6.5 6.5 0 0 0-9.108 9.275ZM6.03 4.97 8 6.94l1.97-1.97a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l1.97 1.97a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-1.97 1.97a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L6.94 8 4.97 6.03a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018Z"/></svg>
|
||||
|
After Width: | Height: | Size: 539 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/></svg>
|
||||
|
After Width: | Height: | Size: 371 B |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 82 KiB |
@@ -0,0 +1,768 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const MAX_RENDERED_RESULTS = 2_000;
|
||||
const MAX_DETAIL_CHARS = 24_000;
|
||||
const MAX_HIGHLIGHTED_RAW_CHARS = 4 * 1024 * 1024;
|
||||
const rawSources = new WeakMap();
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function icon(name) {
|
||||
const safeName = /^[a-z-]+$/.test(name) ? name : "file";
|
||||
return `<span class="octicon icon-${safeName}" aria-hidden="true"></span>`;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime())
|
||||
? ""
|
||||
: new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function elements(parent, localName) {
|
||||
if (!parent?.getElementsByTagName) return [];
|
||||
return [...parent.getElementsByTagName("*")].filter(
|
||||
(element) => element.localName === localName,
|
||||
);
|
||||
}
|
||||
|
||||
function firstElement(parent, localName) {
|
||||
return elements(parent, localName)[0] ?? null;
|
||||
}
|
||||
|
||||
function directChild(parent, localName) {
|
||||
return (
|
||||
[...(parent?.children ?? [])].find(
|
||||
(element) => element.localName === localName,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function elementText(element) {
|
||||
return String(element?.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
function attribute(element, name) {
|
||||
return String(element?.getAttribute(name) ?? "").trim();
|
||||
}
|
||||
|
||||
function outcomeCategory(outcome) {
|
||||
const normalized = String(outcome ?? "")
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[^a-z]/g, "");
|
||||
if (normalized === "passed") return "passed";
|
||||
if (
|
||||
[
|
||||
"failed",
|
||||
"error",
|
||||
"timeout",
|
||||
"aborted",
|
||||
"passedbutrunaborted",
|
||||
"disconnected",
|
||||
].includes(normalized)
|
||||
) {
|
||||
return "failed";
|
||||
}
|
||||
if (
|
||||
["notexecuted", "notrunnable", "inconclusive", "pending"].includes(
|
||||
normalized,
|
||||
)
|
||||
) {
|
||||
return "skipped";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
function outcomeIcon(category) {
|
||||
if (category === "passed") return "check-circle";
|
||||
if (category === "failed") return "x-circle";
|
||||
if (category === "skipped") return "clock";
|
||||
return "workflow";
|
||||
}
|
||||
|
||||
const KNOWN_TRX_OUTCOMES = new Set([
|
||||
"passed", "failed", "completed", "error", "warning",
|
||||
"inconclusive", "aborted", "timeout", "inprogress", "notexecuted",
|
||||
]);
|
||||
|
||||
function summarizedRunOutcome(report) {
|
||||
const normalized = String(report.outcome ?? "").toLowerCase();
|
||||
if (!KNOWN_TRX_OUTCOMES.has(normalized) || normalized === "completed") {
|
||||
if (report.summaryCounts.failed > 0) return "Failed";
|
||||
if (report.summaryCounts.passed > 0) return "Passed";
|
||||
if (report.summaryCounts.skipped > 0) return "Skipped";
|
||||
}
|
||||
return report.outcome;
|
||||
}
|
||||
|
||||
function parseDuration(value) {
|
||||
const duration = String(value ?? "").trim();
|
||||
if (!duration) return null;
|
||||
|
||||
const clock = duration.match(
|
||||
/^(?:(\d+)\.)?(\d{1,2}):(\d{2}):(\d{2}(?:\.\d+)?)$/,
|
||||
);
|
||||
if (clock) {
|
||||
const [, days = "0", hours, minutes, seconds] = clock;
|
||||
return (
|
||||
Number(days) * 86_400_000 +
|
||||
Number(hours) * 3_600_000 +
|
||||
Number(minutes) * 60_000 +
|
||||
Number(seconds) * 1_000
|
||||
);
|
||||
}
|
||||
|
||||
const iso = duration.match(
|
||||
/^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i,
|
||||
);
|
||||
if (!iso) return null;
|
||||
const [, days = "0", hours = "0", minutes = "0", seconds = "0"] = iso;
|
||||
return (
|
||||
Number(days) * 86_400_000 +
|
||||
Number(hours) * 3_600_000 +
|
||||
Number(minutes) * 60_000 +
|
||||
Number(seconds) * 1_000
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(durationMs) {
|
||||
if (!Number.isFinite(durationMs)) return "";
|
||||
if (durationMs < 1_000) {
|
||||
return `${Math.max(0, Math.round(durationMs))} ms`;
|
||||
}
|
||||
if (durationMs < 60_000) {
|
||||
const seconds = durationMs / 1_000;
|
||||
return `${seconds
|
||||
.toFixed(seconds < 10 ? 2 : 1)
|
||||
.replace(/\.0+$|(\.\d*[1-9])0+$/, "$1")} s`;
|
||||
}
|
||||
if (durationMs < 3_600_000) {
|
||||
const minutes = Math.floor(durationMs / 60_000);
|
||||
const seconds = Math.floor((durationMs % 60_000) / 1_000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
const hours = Math.floor(durationMs / 3_600_000);
|
||||
const minutes = Math.floor((durationMs % 3_600_000) / 60_000);
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
function parseReport(value) {
|
||||
const source = String(value ?? "");
|
||||
if (/<!DOCTYPE|<!ENTITY/i.test(source)) {
|
||||
throw new Error(
|
||||
"DTD and entity declarations are not supported in test result previews.",
|
||||
);
|
||||
}
|
||||
|
||||
const documentNode = new DOMParser().parseFromString(
|
||||
source,
|
||||
"application/xml",
|
||||
);
|
||||
const parserError = [...documentNode.getElementsByTagName("*")].find(
|
||||
(element) => element.localName?.toLocaleLowerCase() === "parsererror",
|
||||
);
|
||||
if (parserError) {
|
||||
const detail = elementText(parserError).replace(/\s+/g, " ").slice(0, 240);
|
||||
throw new Error(detail || "The XML document is malformed.");
|
||||
}
|
||||
|
||||
const root = documentNode.documentElement;
|
||||
if (root?.localName !== "TestRun") {
|
||||
throw new Error(
|
||||
"The XML document does not contain a TRX TestRun root element.",
|
||||
);
|
||||
}
|
||||
|
||||
const definitions = new Map();
|
||||
for (const unitTest of elements(root, "UnitTest")) {
|
||||
const testId = attribute(unitTest, "id").toLocaleLowerCase();
|
||||
if (!testId) continue;
|
||||
const method = firstElement(unitTest, "TestMethod");
|
||||
definitions.set(testId, {
|
||||
name: attribute(unitTest, "name"),
|
||||
className: attribute(method, "className"),
|
||||
methodName: attribute(method, "name"),
|
||||
codeBase: attribute(method, "codeBase"),
|
||||
adapterTypeName: attribute(method, "adapterTypeName"),
|
||||
});
|
||||
}
|
||||
|
||||
const testLists = new Map();
|
||||
for (const testList of elements(root, "TestList")) {
|
||||
const id = attribute(testList, "id").toLocaleLowerCase();
|
||||
if (id) testLists.set(id, attribute(testList, "name"));
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const resultsElement = directChild(root, "Results");
|
||||
const isResultElement = (element) =>
|
||||
element.localName?.endsWith("TestResult") ||
|
||||
element.localName === "TestResultAggregation";
|
||||
|
||||
const readResult = (element, depth) => {
|
||||
const testId = attribute(element, "testId").toLocaleLowerCase();
|
||||
const definition = definitions.get(testId) ?? {};
|
||||
const output = directChild(element, "Output");
|
||||
const errorInfo = firstElement(output, "ErrorInfo");
|
||||
const outcome = attribute(element, "outcome") || "Unknown";
|
||||
const testListId = attribute(element, "testListId").toLocaleLowerCase();
|
||||
const durationValue = attribute(element, "duration");
|
||||
const resultFiles = elements(output, "ResultFile")
|
||||
.map((file) => attribute(file, "path"))
|
||||
.filter(Boolean);
|
||||
const properties = elements(output, "Property")
|
||||
.map((property) => {
|
||||
const key =
|
||||
elementText(directChild(property, "Key")) ||
|
||||
attribute(property, "name");
|
||||
const propertyValue =
|
||||
elementText(directChild(property, "Value")) ||
|
||||
attribute(property, "value");
|
||||
return key ? { key, value: propertyValue } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
depth,
|
||||
outcome,
|
||||
category: outcomeCategory(outcome),
|
||||
testName:
|
||||
attribute(element, "testName") ||
|
||||
definition.name ||
|
||||
definition.methodName ||
|
||||
"Unnamed test",
|
||||
className: definition.className || attribute(element, "className"),
|
||||
methodName: definition.methodName,
|
||||
codeBase: definition.codeBase,
|
||||
adapterTypeName: definition.adapterTypeName,
|
||||
computerName: attribute(element, "computerName"),
|
||||
startTime: attribute(element, "startTime"),
|
||||
endTime: attribute(element, "endTime"),
|
||||
durationMs: parseDuration(durationValue),
|
||||
executionId: attribute(element, "executionId"),
|
||||
testId: attribute(element, "testId"),
|
||||
testListName: testLists.get(testListId) ?? "",
|
||||
dataRowInfo: attribute(element, "dataRowInfo"),
|
||||
message: elementText(firstElement(errorInfo, "Message")),
|
||||
stackTrace: elementText(firstElement(errorInfo, "StackTrace")),
|
||||
stdout: elementText(firstElement(output, "StdOut")),
|
||||
stderr: elementText(firstElement(output, "StdErr")),
|
||||
debugTrace: elementText(firstElement(output, "DebugTrace")),
|
||||
resultFiles,
|
||||
properties,
|
||||
};
|
||||
};
|
||||
|
||||
const visitResults = (parent, depth = 0) => {
|
||||
for (const element of parent?.children ?? []) {
|
||||
if (!isResultElement(element)) continue;
|
||||
results.push(readResult(element, depth));
|
||||
const innerResults = directChild(element, "InnerResults");
|
||||
if (innerResults) visitResults(innerResults, depth + 1);
|
||||
}
|
||||
};
|
||||
visitResults(resultsElement);
|
||||
|
||||
const countersElement = firstElement(root, "Counters");
|
||||
const counters = {};
|
||||
for (const counterAttribute of countersElement?.attributes ?? []) {
|
||||
const number = Number(counterAttribute.value);
|
||||
if (Number.isFinite(number) && number >= 0) {
|
||||
counters[counterAttribute.name] = number;
|
||||
}
|
||||
}
|
||||
|
||||
const resultCounts = results.reduce(
|
||||
(counts, result) => {
|
||||
counts[result.category] += 1;
|
||||
return counts;
|
||||
},
|
||||
{ passed: 0, failed: 0, skipped: 0, other: 0 },
|
||||
);
|
||||
const sumCounters = (names, fallback) => {
|
||||
const values = names
|
||||
.filter((name) => Number.isFinite(counters[name]))
|
||||
.map((name) => counters[name]);
|
||||
return values.length > 0
|
||||
? values.reduce((total, number) => total + number, 0)
|
||||
: fallback;
|
||||
};
|
||||
|
||||
const times = firstElement(root, "Times");
|
||||
const startTime = attribute(times, "start");
|
||||
const finishTime = attribute(times, "finish");
|
||||
const startTimestamp = new Date(startTime).getTime();
|
||||
const finishTimestamp = new Date(finishTime).getTime();
|
||||
const durationMs =
|
||||
Number.isFinite(startTimestamp) &&
|
||||
Number.isFinite(finishTimestamp) &&
|
||||
finishTimestamp >= startTimestamp
|
||||
? finishTimestamp - startTimestamp
|
||||
: null;
|
||||
const summaryElement = firstElement(root, "ResultSummary");
|
||||
|
||||
return {
|
||||
name: attribute(root, "name") || "Test run",
|
||||
runId: attribute(root, "id"),
|
||||
user: attribute(root, "runUser"),
|
||||
computerName: attribute(root, "computerName"),
|
||||
outcome: attribute(summaryElement, "outcome") || "Unknown",
|
||||
startTime,
|
||||
finishTime,
|
||||
durationMs,
|
||||
resultCounts,
|
||||
summaryCounts: {
|
||||
total: Number.isFinite(counters.total) ? counters.total : results.length,
|
||||
passed: sumCounters(["passed"], resultCounts.passed),
|
||||
failed: sumCounters(
|
||||
[
|
||||
"failed",
|
||||
"error",
|
||||
"timeout",
|
||||
"aborted",
|
||||
"passedButRunAborted",
|
||||
"disconnected",
|
||||
],
|
||||
resultCounts.failed,
|
||||
),
|
||||
skipped: sumCounters(
|
||||
["notExecuted", "notRunnable", "inconclusive", "pending"],
|
||||
resultCounts.skipped,
|
||||
),
|
||||
},
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
function renderMetadataItem(label, value, { mono = false } = {}) {
|
||||
if (!value) return "";
|
||||
return `
|
||||
<div class="trx-meta-item">
|
||||
<dt>${escapeHtml(label)}</dt>
|
||||
<dd${mono ? ' class="mono"' : ""}>${escapeHtml(value)}</dd>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function truncateDetail(value) {
|
||||
const text = String(value ?? "");
|
||||
if (text.length <= MAX_DETAIL_CHARS) {
|
||||
return { text, truncated: false };
|
||||
}
|
||||
return {
|
||||
text: text.slice(0, MAX_DETAIL_CHARS),
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
function renderDetailBlock(label, value) {
|
||||
if (!value) return "";
|
||||
const detail = truncateDetail(value);
|
||||
return `
|
||||
<section class="trx-output-block">
|
||||
<h4>${escapeHtml(label)}</h4>
|
||||
<pre>${escapeHtml(detail.text)}</pre>
|
||||
${
|
||||
detail.truncated
|
||||
? `<p class="trx-truncation-note">Output truncated after ${MAX_DETAIL_CHARS.toLocaleString()} characters. View the raw XML for the full value.</p>`
|
||||
: ""
|
||||
}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderResult(result, open = false) {
|
||||
const secondaryParts = [];
|
||||
if (result.className) secondaryParts.push(result.className);
|
||||
if (result.dataRowInfo) secondaryParts.push(`Data row ${result.dataRowInfo}`);
|
||||
if (result.category === "failed" && result.message) {
|
||||
secondaryParts.push(
|
||||
result.message.length > 180
|
||||
? `${result.message.slice(0, 179)}...`
|
||||
: result.message,
|
||||
);
|
||||
}
|
||||
const searchValue = [
|
||||
result.testName,
|
||||
result.className,
|
||||
result.methodName,
|
||||
result.outcome,
|
||||
result.message,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLocaleLowerCase()
|
||||
.slice(0, 2_000);
|
||||
const resultDuration = formatDuration(result.durationMs);
|
||||
const metadata = [
|
||||
renderMetadataItem("Class", result.className, { mono: true }),
|
||||
renderMetadataItem("Method", result.methodName, { mono: true }),
|
||||
renderMetadataItem("Duration", resultDuration),
|
||||
renderMetadataItem("Computer", result.computerName),
|
||||
renderMetadataItem(
|
||||
"Started",
|
||||
result.startTime ? formatDate(result.startTime) : "",
|
||||
),
|
||||
renderMetadataItem(
|
||||
"Finished",
|
||||
result.endTime ? formatDate(result.endTime) : "",
|
||||
),
|
||||
renderMetadataItem("Test list", result.testListName),
|
||||
renderMetadataItem("Adapter", result.adapterTypeName, { mono: true }),
|
||||
renderMetadataItem("Code base", result.codeBase, { mono: true }),
|
||||
renderMetadataItem("Test ID", result.testId, { mono: true }),
|
||||
renderMetadataItem("Execution ID", result.executionId, { mono: true }),
|
||||
].join("");
|
||||
const resultFiles =
|
||||
result.resultFiles.length > 0
|
||||
? `
|
||||
<section class="trx-result-files">
|
||||
<h4>Result files</h4>
|
||||
<ul>${result.resultFiles
|
||||
.map((path) => `<li class="mono">${escapeHtml(path)}</li>`)
|
||||
.join("")}</ul>
|
||||
</section>
|
||||
`
|
||||
: "";
|
||||
const properties =
|
||||
result.properties.length > 0
|
||||
? `
|
||||
<section class="trx-properties">
|
||||
<h4>Properties</h4>
|
||||
<dl>${result.properties
|
||||
.map(
|
||||
({ key, value }) => `
|
||||
<div>
|
||||
<dt>${escapeHtml(key)}</dt>
|
||||
<dd>${escapeHtml(value)}</dd>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("")}</dl>
|
||||
</section>
|
||||
`
|
||||
: "";
|
||||
const details = [
|
||||
metadata ? `<dl class="trx-result-metadata">${metadata}</dl>` : "",
|
||||
renderDetailBlock("Error message", result.message),
|
||||
renderDetailBlock("Stack trace", result.stackTrace),
|
||||
renderDetailBlock("Standard output", result.stdout),
|
||||
renderDetailBlock("Standard error", result.stderr),
|
||||
renderDetailBlock("Debug trace", result.debugTrace),
|
||||
resultFiles,
|
||||
properties,
|
||||
].join("");
|
||||
|
||||
return `
|
||||
<details
|
||||
class="trx-result trx-outcome-${result.category}"
|
||||
data-trx-result
|
||||
data-trx-category="${result.category}"
|
||||
data-trx-search="${escapeHtml(searchValue)}"
|
||||
style="--trx-depth: ${Math.min(result.depth, 4)}"
|
||||
${open ? "open" : ""}
|
||||
>
|
||||
<summary>
|
||||
<span class="trx-result-chevron">${icon("chevron-right")}</span>
|
||||
<span class="trx-result-status">${icon(outcomeIcon(result.category))}</span>
|
||||
<span class="trx-result-copy">
|
||||
<span class="trx-result-name">${escapeHtml(result.testName)}</span>
|
||||
${
|
||||
secondaryParts.length > 0
|
||||
? `<span class="trx-result-secondary">${escapeHtml(secondaryParts.join(" | "))}</span>`
|
||||
: ""
|
||||
}
|
||||
</span>
|
||||
<span class="trx-result-duration">${escapeHtml(resultDuration)}</span>
|
||||
<span class="trx-outcome-badge trx-outcome-${result.category}">${escapeHtml(result.outcome)}</span>
|
||||
</summary>
|
||||
<div class="trx-result-details">
|
||||
${details || '<p class="trx-detail-empty">No additional details were recorded for this result.</p>'}
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMetric(label, value, category, iconName) {
|
||||
return `
|
||||
<div class="trx-metric trx-metric-${category}">
|
||||
<span class="trx-metric-icon">${icon(iconName)}</span>
|
||||
<span class="trx-metric-value">${Number(value).toLocaleString()}</span>
|
||||
<span class="trx-metric-label">${escapeHtml(label)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderFilter(label, category, count, pressed = false) {
|
||||
return `
|
||||
<button
|
||||
class="trx-filter"
|
||||
type="button"
|
||||
data-trx-filter="${category}"
|
||||
aria-pressed="${pressed}"
|
||||
>
|
||||
${escapeHtml(label)}
|
||||
<span class="Counter Counter--secondary">${Number(count).toLocaleString()}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderParseError(source, message, highlightXml) {
|
||||
const highlighted =
|
||||
source.length <= MAX_HIGHLIGHTED_RAW_CHARS
|
||||
? highlightXml(source)
|
||||
: escapeHtml(source);
|
||||
return `
|
||||
<div class="trx-preview">
|
||||
<section class="trx-error" role="alert">
|
||||
<span class="trx-error-icon">${icon("x-circle")}</span>
|
||||
<div>
|
||||
<h2>Unable to parse test results</h2>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="trx-raw-panel">
|
||||
<div class="trx-section-heading">
|
||||
<div>
|
||||
<span class="trx-eyebrow">Fallback</span>
|
||||
<h3>Raw XML</h3>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="text-preview syntax-code syntax-xml trx-raw-source"><code>${highlighted}</code></pre>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function render(value, preview, helpers = {}) {
|
||||
const source = String(value ?? "");
|
||||
const highlightXml =
|
||||
typeof helpers.highlightXml === "function"
|
||||
? helpers.highlightXml
|
||||
: escapeHtml;
|
||||
let report;
|
||||
try {
|
||||
report = parseReport(source);
|
||||
} catch (error) {
|
||||
preview.innerHTML = renderParseError(
|
||||
source,
|
||||
error instanceof Error ? error.message : "The TRX document is invalid.",
|
||||
highlightXml,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryOrder = { failed: 0, other: 1, skipped: 2, passed: 3 };
|
||||
const renderedResults = [...report.results]
|
||||
.map((result, index) => ({ result, index }))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
categoryOrder[left.result.category] -
|
||||
categoryOrder[right.result.category] || left.index - right.index,
|
||||
)
|
||||
.slice(0, MAX_RENDERED_RESULTS)
|
||||
.map(({ result }) => result);
|
||||
const runOutcome = summarizedRunOutcome(report);
|
||||
const outcome = outcomeCategory(runOutcome);
|
||||
const limited = renderedResults.length < report.results.length;
|
||||
const runDuration = formatDuration(report.durationMs);
|
||||
const runMetadata = [
|
||||
renderMetadataItem(
|
||||
"Started",
|
||||
report.startTime ? formatDate(report.startTime) : "",
|
||||
),
|
||||
renderMetadataItem(
|
||||
"Finished",
|
||||
report.finishTime ? formatDate(report.finishTime) : "",
|
||||
),
|
||||
renderMetadataItem("Duration", runDuration),
|
||||
renderMetadataItem("Run by", report.user),
|
||||
renderMetadataItem("Computer", report.computerName),
|
||||
renderMetadataItem("Run ID", report.runId, { mono: true }),
|
||||
].join("");
|
||||
|
||||
preview.innerHTML = `
|
||||
<div
|
||||
class="trx-preview"
|
||||
data-trx-total="${report.results.length}"
|
||||
data-trx-rendered="${renderedResults.length}"
|
||||
>
|
||||
<div data-trx-panel="structured">
|
||||
<section class="trx-overview">
|
||||
<div class="trx-run-heading">
|
||||
<div>
|
||||
<span class="trx-eyebrow">Test run</span>
|
||||
<h2>${escapeHtml(report.name)}</h2>
|
||||
</div>
|
||||
<span class="trx-run-outcome trx-outcome-${outcome}">
|
||||
${icon(outcomeIcon(outcome))}
|
||||
${escapeHtml(runOutcome)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="trx-metrics">
|
||||
${renderMetric("Total", report.summaryCounts.total, "all", "workflow")}
|
||||
${renderMetric("Passed", report.summaryCounts.passed, "passed", "check-circle")}
|
||||
${renderMetric("Failed", report.summaryCounts.failed, "failed", "x-circle")}
|
||||
${renderMetric("Skipped", report.summaryCounts.skipped, "skipped", "clock")}
|
||||
</div>
|
||||
${runMetadata ? `<dl class="trx-run-metadata">${runMetadata}</dl>` : ""}
|
||||
</section>
|
||||
|
||||
<section class="trx-results-panel">
|
||||
<div class="trx-section-heading">
|
||||
<div>
|
||||
<span class="trx-eyebrow">Details</span>
|
||||
<h3>Test results</h3>
|
||||
<p data-trx-result-count aria-live="polite"></p>
|
||||
</div>
|
||||
<button class="app-button app-button-small" type="button" data-trx-view="raw">
|
||||
${icon("code")}
|
||||
View raw XML
|
||||
</button>
|
||||
</div>
|
||||
<div class="trx-controls">
|
||||
<label class="trx-search">
|
||||
${icon("search")}
|
||||
<span class="sr-only">Search test results</span>
|
||||
<input
|
||||
class="form-control"
|
||||
type="search"
|
||||
placeholder="Search tests..."
|
||||
autocomplete="off"
|
||||
data-trx-search-input
|
||||
/>
|
||||
</label>
|
||||
<div class="trx-filters" role="group" aria-label="Filter test outcomes">
|
||||
${renderFilter("All", "all", report.results.length, true)}
|
||||
${renderFilter("Passed", "passed", report.resultCounts.passed)}
|
||||
${renderFilter("Failed", "failed", report.resultCounts.failed)}
|
||||
${renderFilter("Skipped", "skipped", report.resultCounts.skipped)}
|
||||
${renderFilter("Other", "other", report.resultCounts.other)}
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
limited
|
||||
? `
|
||||
<div class="trx-limit-note">
|
||||
${icon("eye")}
|
||||
Showing the first ${renderedResults.length.toLocaleString()} of ${report.results.length.toLocaleString()} result records to keep the preview responsive. Raw XML contains the complete report.
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<div class="trx-result-list">
|
||||
${renderedResults
|
||||
.map((result, index) =>
|
||||
renderResult(
|
||||
result,
|
||||
index === 0 && result.category === "failed",
|
||||
),
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
<div class="trx-no-results" data-trx-no-results hidden>
|
||||
${icon("search")}
|
||||
<h4>No matching tests</h4>
|
||||
<p>Try another search or outcome filter.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="trx-raw-panel" data-trx-panel="raw" hidden>
|
||||
<div class="trx-section-heading">
|
||||
<div>
|
||||
<span class="trx-eyebrow">Source</span>
|
||||
<h3>Raw XML</h3>
|
||||
</div>
|
||||
<button class="app-button app-button-small" type="button" data-trx-view="structured">
|
||||
${icon("chevron-left")}
|
||||
Back to results
|
||||
</button>
|
||||
</div>
|
||||
<div class="trx-limit-note" data-trx-raw-note hidden>
|
||||
${icon("eye")}
|
||||
Syntax highlighting is disabled for large reports to keep the source view responsive.
|
||||
</div>
|
||||
<pre class="text-preview syntax-code syntax-xml trx-raw-source"><code data-trx-raw-code></code></pre>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
const trxPreview = preview.querySelector(".trx-preview");
|
||||
rawSources.set(trxPreview, { source, highlightXml });
|
||||
applyFilters(trxPreview);
|
||||
}
|
||||
|
||||
function showView(preview, viewName) {
|
||||
if (!preview) return;
|
||||
if (viewName === "raw") {
|
||||
const rawSource = rawSources.get(preview);
|
||||
const code = preview.querySelector("[data-trx-raw-code]");
|
||||
if (rawSource && code && code.dataset.trxLoaded !== "true") {
|
||||
if (rawSource.source.length > MAX_HIGHLIGHTED_RAW_CHARS) {
|
||||
code.textContent = rawSource.source;
|
||||
const note = preview.querySelector("[data-trx-raw-note]");
|
||||
if (note) note.hidden = false;
|
||||
} else {
|
||||
code.innerHTML = rawSource.highlightXml(rawSource.source);
|
||||
}
|
||||
code.dataset.trxLoaded = "true";
|
||||
}
|
||||
}
|
||||
for (const panel of preview.querySelectorAll("[data-trx-panel]")) {
|
||||
panel.hidden = panel.dataset.trxPanel !== viewName;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters(preview) {
|
||||
if (!preview) return;
|
||||
const query = String(
|
||||
preview.querySelector("[data-trx-search-input]")?.value ?? "",
|
||||
)
|
||||
.trim()
|
||||
.toLocaleLowerCase();
|
||||
const terms = query.split(/\s+/).filter(Boolean);
|
||||
const category =
|
||||
preview.querySelector('[data-trx-filter][aria-pressed="true"]')?.dataset
|
||||
.trxFilter ?? "all";
|
||||
const results = [...preview.querySelectorAll("[data-trx-result]")];
|
||||
let visible = 0;
|
||||
for (const result of results) {
|
||||
const matchesCategory =
|
||||
category === "all" || result.dataset.trxCategory === category;
|
||||
const searchValue = result.dataset.trxSearch ?? "";
|
||||
const matchesQuery = terms.every((term) => searchValue.includes(term));
|
||||
result.hidden = !(matchesCategory && matchesQuery);
|
||||
if (!result.hidden) visible += 1;
|
||||
}
|
||||
|
||||
const total = Number(preview.dataset.trxTotal) || results.length;
|
||||
const rendered = Number(preview.dataset.trxRendered) || results.length;
|
||||
const count = preview.querySelector("[data-trx-result-count]");
|
||||
if (count) {
|
||||
count.textContent =
|
||||
total > rendered
|
||||
? `${visible.toLocaleString()} matching in the first ${rendered.toLocaleString()} of ${total.toLocaleString()} results`
|
||||
: `${visible.toLocaleString()} of ${total.toLocaleString()} results`;
|
||||
}
|
||||
const noResults = preview.querySelector("[data-trx-no-results]");
|
||||
if (noResults) noResults.hidden = visible > 0;
|
||||
}
|
||||
|
||||
globalThis.TrxPreview = Object.freeze({
|
||||
applyFilters,
|
||||
parse: parseReport,
|
||||
render,
|
||||
showView,
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,63 @@
|
||||
function trackedOperation(callback) {
|
||||
const operation = Promise.resolve().then(callback);
|
||||
return {
|
||||
operation,
|
||||
// Waiters need ordering only; the initiating maintenance request owns failures.
|
||||
barrier: operation.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export class CacheMaintenanceCoordinator {
|
||||
constructor() {
|
||||
this.artifactDeletions = new Map();
|
||||
this.cacheClear = null;
|
||||
}
|
||||
|
||||
inspectionBarrier(artifactId) {
|
||||
return (
|
||||
this.cacheClear?.barrier ??
|
||||
this.artifactDeletions.get(String(artifactId))?.barrier ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
async deleteArtifact(artifactId, callback) {
|
||||
const id = String(artifactId);
|
||||
while (true) {
|
||||
const existing = this.artifactDeletions.get(id);
|
||||
if (existing) return existing.operation;
|
||||
if (!this.cacheClear) break;
|
||||
await this.cacheClear.barrier;
|
||||
}
|
||||
|
||||
const tracked = trackedOperation(callback);
|
||||
this.artifactDeletions.set(id, tracked);
|
||||
try {
|
||||
return await tracked.operation;
|
||||
} finally {
|
||||
if (this.artifactDeletions.get(id) === tracked) {
|
||||
this.artifactDeletions.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async clearCache(callback) {
|
||||
if (this.cacheClear) return this.cacheClear.operation;
|
||||
|
||||
const tracked = trackedOperation(async () => {
|
||||
await Promise.all(
|
||||
[...this.artifactDeletions.values()].map((entry) => entry.barrier),
|
||||
);
|
||||
return callback();
|
||||
});
|
||||
this.cacheClear = tracked;
|
||||
try {
|
||||
return await tracked.operation;
|
||||
} finally {
|
||||
if (this.cacheClear === tracked) this.cacheClear = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
import { createWriteStream } from "node:fs";
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
statfs,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import {
|
||||
ARTIFACT_DOWNLOAD_IDLE_TIMEOUT_MS,
|
||||
CACHE_ROOT,
|
||||
MAX_ARCHIVE_BYTES,
|
||||
MAX_INLINE_PREVIEW_BYTES,
|
||||
MIN_CACHE_FREE_BYTES,
|
||||
} from "./constants.mjs";
|
||||
import { CacheMaintenanceCoordinator } from "./cache-coordinator.mjs";
|
||||
import { analyzeArtifact, hasRootIndexHtml } from "./detector.mjs";
|
||||
import { getArtifact, openArtifactDownload } from "./github.mjs";
|
||||
import { findZipEntry, readEntryPrefix, readZipEntry, readZipIndex } from "./zip.mjs";
|
||||
|
||||
const downloads = new Map();
|
||||
const indexCache = new Map();
|
||||
const maintenance = new CacheMaintenanceCoordinator();
|
||||
let reservedDownloadBytes = 0;
|
||||
let metadataWriteSequence = 0;
|
||||
|
||||
function normalizeArtifactId(value) {
|
||||
const id = Number.parseInt(value, 10);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) {
|
||||
throw new Error("Artifact id must be a positive integer.");
|
||||
}
|
||||
return String(id);
|
||||
}
|
||||
|
||||
function pathsFor(value) {
|
||||
const id = normalizeArtifactId(value);
|
||||
return {
|
||||
id,
|
||||
archive: join(CACHE_ROOT, `${id}.zip`),
|
||||
metadata: join(CACHE_ROOT, `${id}.json`),
|
||||
};
|
||||
}
|
||||
|
||||
async function writeJsonAtomic(path, value) {
|
||||
const temporary = `${path}.${process.pid}.${++metadataWriteSequence}.tmp`;
|
||||
try {
|
||||
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
await rename(temporary, path);
|
||||
} catch (error) {
|
||||
await rm(temporary, { force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function readMetadataFile(path) {
|
||||
try {
|
||||
return JSON.parse(await readFile(path, "utf8"));
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return null;
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error(`Cached artifact metadata is invalid: ${path}`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const bytes = Math.max(0, Number(value) || 0);
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KiB", "MiB", "GiB", "TiB"];
|
||||
let size = bytes;
|
||||
let unit = -1;
|
||||
do {
|
||||
size /= 1024;
|
||||
unit++;
|
||||
} while (size >= 1024 && unit < units.length - 1);
|
||||
return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
async function downloadCapacityBytes() {
|
||||
const filesystem = await statfs(CACHE_ROOT);
|
||||
const available = Number(filesystem.bavail) * Number(filesystem.bsize);
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
MAX_ARCHIVE_BYTES,
|
||||
available - MIN_CACHE_FREE_BYTES - reservedDownloadBytes,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function assertDownloadFits(bytes, capacity) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return;
|
||||
if (bytes > MAX_ARCHIVE_BYTES) {
|
||||
throw new Error("Artifact exceeds the 4 GiB ZIP download limit.");
|
||||
}
|
||||
if (bytes > capacity) {
|
||||
throw new Error(
|
||||
`Not enough free disk space to cache this artifact. ${formatBytes(bytes)} is required, with ${formatBytes(capacity)} available after the safety reserve.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadArchive(
|
||||
token,
|
||||
repository,
|
||||
artifact,
|
||||
archivePath,
|
||||
onProgress,
|
||||
externalSignal = null,
|
||||
) {
|
||||
const declaredBytes = Number(artifact.sizeInBytes);
|
||||
let reservedBytes = 0;
|
||||
const reserve = async (totalBytes) => {
|
||||
if (!Number.isFinite(totalBytes) || totalBytes <= reservedBytes) return;
|
||||
assertDownloadFits(totalBytes, MAX_ARCHIVE_BYTES);
|
||||
const additionalBytes = totalBytes - reservedBytes;
|
||||
const capacity = await downloadCapacityBytes();
|
||||
if (additionalBytes > capacity) {
|
||||
throw new Error(
|
||||
`Not enough free disk space to cache this artifact. ${formatBytes(totalBytes)} is required, with ${formatBytes(capacity + reservedBytes)} available after the safety reserve.`,
|
||||
);
|
||||
}
|
||||
reservedDownloadBytes += additionalBytes;
|
||||
reservedBytes = totalBytes;
|
||||
};
|
||||
const releaseReservation = () => {
|
||||
reservedDownloadBytes = Math.max(0, reservedDownloadBytes - reservedBytes);
|
||||
reservedBytes = 0;
|
||||
};
|
||||
await reserve(declaredBytes);
|
||||
|
||||
const controller = new AbortController();
|
||||
if (externalSignal?.aborted) {
|
||||
controller.abort(externalSignal.reason);
|
||||
} else if (externalSignal) {
|
||||
externalSignal.addEventListener("abort", () => controller.abort(externalSignal.reason), { once: true });
|
||||
}
|
||||
let idleTimer;
|
||||
const resetIdleTimeout = () => {
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
controller.abort(new Error("Artifact download stalled for 60 seconds."));
|
||||
}, ARTIFACT_DOWNLOAD_IDLE_TIMEOUT_MS);
|
||||
};
|
||||
resetIdleTimeout();
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await openArtifactDownload(token, repository, artifact.id, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
clearTimeout(idleTimer);
|
||||
releaseReservation();
|
||||
if (controller.signal.aborted && controller.signal.reason instanceof Error) {
|
||||
throw controller.signal.reason;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const expectedLength = Number.parseInt(response.headers.get("content-length") ?? "", 10);
|
||||
try {
|
||||
await reserve(expectedLength);
|
||||
} catch (error) {
|
||||
clearTimeout(idleTimer);
|
||||
controller.abort(error);
|
||||
releaseReservation();
|
||||
throw error;
|
||||
}
|
||||
const totalBytes =
|
||||
Number.isFinite(expectedLength) && expectedLength > 0
|
||||
? expectedLength
|
||||
: Number.isFinite(declaredBytes) && declaredBytes > 0
|
||||
? declaredBytes
|
||||
: null;
|
||||
|
||||
const temporary = `${archivePath}.${process.pid}.${Date.now()}.part`;
|
||||
let received = 0;
|
||||
let lastReportAt = 0;
|
||||
let bytesPerSecond = 0;
|
||||
const samples = [{ at: Date.now(), bytes: 0 }];
|
||||
const report = (force = false) => {
|
||||
if (!onProgress) return;
|
||||
const now = Date.now();
|
||||
if (!force && now - lastReportAt < 250) return;
|
||||
samples.push({ at: now, bytes: received });
|
||||
while (samples.length > 2 && samples[1].at < now - 5_000) samples.shift();
|
||||
const first = samples[0];
|
||||
const elapsedSeconds = (now - first.at) / 1_000;
|
||||
bytesPerSecond =
|
||||
elapsedSeconds >= 0.2 ? (received - first.bytes) / elapsedSeconds : 0;
|
||||
const percent = totalBytes
|
||||
? Math.min(100, (received / totalBytes) * 100)
|
||||
: null;
|
||||
onProgress({
|
||||
artifactId: artifact.id,
|
||||
artifactName: artifact.name,
|
||||
stage: "downloading",
|
||||
receivedBytes: received,
|
||||
totalBytes,
|
||||
percent,
|
||||
bytesPerSecond,
|
||||
etaSeconds:
|
||||
totalBytes && bytesPerSecond > 0
|
||||
? Math.max(0, (totalBytes - received) / bytesPerSecond)
|
||||
: null,
|
||||
});
|
||||
lastReportAt = now;
|
||||
};
|
||||
report(true);
|
||||
|
||||
const limiter = new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
received += chunk.length;
|
||||
if (received > MAX_ARCHIVE_BYTES) {
|
||||
callback(new Error("Artifact exceeded the 4 GiB ZIP download limit."));
|
||||
return;
|
||||
}
|
||||
if (reservedBytes > 0 && received > reservedBytes) {
|
||||
callback(new Error("Artifact download exhausted the available cache space."));
|
||||
return;
|
||||
}
|
||||
resetIdleTimeout();
|
||||
report();
|
||||
callback(null, chunk);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body),
|
||||
limiter,
|
||||
createWriteStream(temporary, { flags: "wx" }),
|
||||
);
|
||||
report(true);
|
||||
await rename(temporary, archivePath);
|
||||
} catch (error) {
|
||||
await rm(temporary, { force: true });
|
||||
if (controller.signal.aborted && controller.signal.reason instanceof Error) {
|
||||
throw controller.signal.reason;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(idleTimer);
|
||||
releaseReservation();
|
||||
}
|
||||
return {
|
||||
bytesPerSecond,
|
||||
receivedBytes: received,
|
||||
totalBytes: totalBytes ?? received,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildMetadata(token, repository, artifactId, onProgress, signal = null) {
|
||||
const paths = pathsFor(artifactId);
|
||||
const artifact = await getArtifact(token, repository, paths.id);
|
||||
if (artifact.expired) {
|
||||
throw new Error("This GitHub Actions artifact has expired.");
|
||||
}
|
||||
|
||||
await mkdir(CACHE_ROOT, { recursive: true });
|
||||
onProgress?.({
|
||||
artifactId: artifact.id,
|
||||
artifactName: artifact.name,
|
||||
stage: "preparing",
|
||||
receivedBytes: 0,
|
||||
totalBytes: artifact.sizeInBytes,
|
||||
percent: 0,
|
||||
bytesPerSecond: 0,
|
||||
etaSeconds: null,
|
||||
});
|
||||
const transfer = await downloadArchive(
|
||||
token,
|
||||
repository,
|
||||
artifact,
|
||||
paths.archive,
|
||||
onProgress,
|
||||
signal,
|
||||
);
|
||||
const compressedBytes = transfer.receivedBytes;
|
||||
try {
|
||||
onProgress?.({
|
||||
artifactId: artifact.id,
|
||||
artifactName: artifact.name,
|
||||
stage: "indexing",
|
||||
receivedBytes: compressedBytes,
|
||||
totalBytes: transfer.totalBytes,
|
||||
percent: 100,
|
||||
bytesPerSecond: transfer.bytesPerSecond,
|
||||
etaSeconds: 0,
|
||||
});
|
||||
const index = await readZipIndex(paths.archive);
|
||||
const PREFIX_BYTES = 8 * 1024;
|
||||
const analysis = await analyzeArtifact(index, async (entry) => {
|
||||
return readEntryPrefix(paths.archive, entry, PREFIX_BYTES);
|
||||
});
|
||||
const timestamp = new Date().toISOString();
|
||||
const metadata = {
|
||||
artifact,
|
||||
repository,
|
||||
downloadedAt: timestamp,
|
||||
lastAccessedAt: timestamp,
|
||||
compressedBytes,
|
||||
analysis,
|
||||
};
|
||||
await writeJsonAtomic(paths.metadata, metadata);
|
||||
indexCache.set(paths.id, { archivePath: paths.archive, index });
|
||||
onProgress?.({
|
||||
artifactId: artifact.id,
|
||||
artifactName: artifact.name,
|
||||
stage: "ready",
|
||||
receivedBytes: compressedBytes,
|
||||
totalBytes: transfer.totalBytes,
|
||||
percent: 100,
|
||||
bytesPerSecond: transfer.bytesPerSecond,
|
||||
etaSeconds: 0,
|
||||
});
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
await Promise.all([
|
||||
rm(paths.archive, { force: true }),
|
||||
rm(paths.metadata, { force: true }),
|
||||
]);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function cachedMetadata(artifactId) {
|
||||
const paths = pathsFor(artifactId);
|
||||
const metadata = await readMetadataFile(paths.metadata);
|
||||
if (!metadata) return null;
|
||||
try {
|
||||
const archive = await stat(paths.archive);
|
||||
if (!archive.isFile()) return null;
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export async function inspectArtifact(
|
||||
token,
|
||||
repository,
|
||||
artifactId,
|
||||
{ onProgress } = {},
|
||||
) {
|
||||
const paths = pathsFor(artifactId);
|
||||
while (true) {
|
||||
const barrier = maintenance.inspectionBarrier(paths.id);
|
||||
if (!barrier) break;
|
||||
await barrier;
|
||||
}
|
||||
const key = `${repository}:${paths.id}`;
|
||||
const active = downloads.get(key);
|
||||
if (active) {
|
||||
if (onProgress) active.listeners.add(onProgress);
|
||||
try {
|
||||
return await active.operation;
|
||||
} finally {
|
||||
if (onProgress) active.listeners.delete(onProgress);
|
||||
}
|
||||
}
|
||||
|
||||
const listeners = new Set(onProgress ? [onProgress] : []);
|
||||
const emit = (progress) => {
|
||||
for (const listener of listeners) listener(progress);
|
||||
};
|
||||
const externalController = new AbortController();
|
||||
const operation = (async () => {
|
||||
const existing = await cachedMetadata(paths.id);
|
||||
if (existing?.repository === repository && existing.analysis) {
|
||||
existing.lastAccessedAt = new Date().toISOString();
|
||||
await writeJsonAtomic(paths.metadata, existing);
|
||||
emit({
|
||||
artifactId: Number(paths.id),
|
||||
artifactName: existing.artifact?.name ?? `Artifact ${paths.id}`,
|
||||
stage: "ready",
|
||||
receivedBytes: existing.compressedBytes,
|
||||
totalBytes: existing.compressedBytes,
|
||||
percent: 100,
|
||||
bytesPerSecond: 0,
|
||||
etaSeconds: 0,
|
||||
cached: true,
|
||||
});
|
||||
return existing;
|
||||
}
|
||||
if (existing) {
|
||||
indexCache.delete(paths.id);
|
||||
await removeArtifactFiles(paths);
|
||||
}
|
||||
return buildMetadata(token, repository, paths.id, emit, externalController.signal);
|
||||
})();
|
||||
const download = { listeners, operation, abort: () => externalController.abort(new Error("Cache cleared.")) };
|
||||
downloads.set(key, download);
|
||||
try {
|
||||
return await operation;
|
||||
} catch (error) {
|
||||
emit({
|
||||
artifactId: Number(paths.id),
|
||||
stage: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
if (downloads.get(key) === download) downloads.delete(key);
|
||||
listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCachedArtifact(artifactId, { touch = true } = {}) {
|
||||
const paths = pathsFor(artifactId);
|
||||
const metadata = await cachedMetadata(paths.id);
|
||||
if (!metadata) return null;
|
||||
if (touch) {
|
||||
metadata.lastAccessedAt = new Date().toISOString();
|
||||
await writeJsonAtomic(paths.metadata, metadata);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
async function getIndex(artifactId) {
|
||||
const paths = pathsFor(artifactId);
|
||||
const cached = indexCache.get(paths.id);
|
||||
if (cached?.archivePath === paths.archive) return cached.index;
|
||||
const index = await readZipIndex(paths.archive);
|
||||
indexCache.set(paths.id, { archivePath: paths.archive, index });
|
||||
return index;
|
||||
}
|
||||
|
||||
export async function getCachedEntry(artifactId, entryPath) {
|
||||
const paths = pathsFor(artifactId);
|
||||
const metadata = await cachedMetadata(paths.id);
|
||||
if (!metadata) throw new Error("Artifact is not cached. Inspect it first.");
|
||||
const index = await getIndex(paths.id);
|
||||
const entry = findZipEntry(index, entryPath);
|
||||
if (!entry) throw new Error(`File was not found in the artifact: ${entryPath}`);
|
||||
return { metadata, archivePath: paths.archive, index, entry };
|
||||
}
|
||||
|
||||
export async function readCachedEntry(artifactId, entryPath, maxBytes = MAX_INLINE_PREVIEW_BYTES) {
|
||||
const context = await getCachedEntry(artifactId, entryPath);
|
||||
return {
|
||||
...context,
|
||||
content: await readZipEntry(context.archivePath, context.entry, maxBytes),
|
||||
};
|
||||
}
|
||||
|
||||
async function removeArtifactFiles(paths) {
|
||||
await Promise.all([
|
||||
rm(paths.archive, { force: true }),
|
||||
rm(paths.metadata, { force: true }),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function deleteCachedArtifact(artifactId) {
|
||||
const paths = pathsFor(artifactId);
|
||||
return maintenance.deleteArtifact(paths.id, async () => {
|
||||
indexCache.delete(paths.id);
|
||||
const suffix = `:${paths.id}`;
|
||||
const matches = [...downloads.entries()]
|
||||
.filter(([key]) => key.endsWith(suffix))
|
||||
.map(([, entry]) => entry);
|
||||
for (const entry of matches) entry.abort?.();
|
||||
await Promise.allSettled(matches.map((entry) => entry.operation));
|
||||
await removeArtifactFiles(paths);
|
||||
return { deleted: paths.id };
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearArtifactCache() {
|
||||
return maintenance.clearCache(async () => {
|
||||
const inflight = [...downloads.values()];
|
||||
for (const entry of inflight) entry.abort?.();
|
||||
await Promise.allSettled(inflight.map((entry) => entry.operation));
|
||||
indexCache.clear();
|
||||
await rm(CACHE_ROOT, { recursive: true, force: true });
|
||||
await mkdir(CACHE_ROOT, { recursive: true });
|
||||
return { cleared: true };
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCacheSummary() {
|
||||
await mkdir(CACHE_ROOT, { recursive: true });
|
||||
const files = await readdir(CACHE_ROOT, { withFileTypes: true });
|
||||
const metadataFiles = files.filter(
|
||||
(entry) => entry.isFile() && /^\d+\.json$/.test(entry.name),
|
||||
);
|
||||
const artifacts = [];
|
||||
const errors = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
for (const file of metadataFiles) {
|
||||
const id = file.name.slice(0, -5);
|
||||
try {
|
||||
const metadata = await cachedMetadata(id);
|
||||
if (!metadata) continue;
|
||||
const archive = await stat(pathsFor(id).archive);
|
||||
totalBytes += archive.size;
|
||||
artifacts.push({
|
||||
id,
|
||||
name: metadata.artifact?.name ?? `Artifact ${id}`,
|
||||
repository: metadata.repository,
|
||||
bytes: archive.size,
|
||||
downloadedAt: metadata.downloadedAt,
|
||||
lastAccessedAt: metadata.lastAccessedAt,
|
||||
primary:
|
||||
["html", "static-site"].includes(metadata.analysis?.primary?.kind) &&
|
||||
!hasRootIndexHtml(metadata.analysis?.entries ?? [])
|
||||
? null
|
||||
: metadata.analysis?.primary ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
id,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
artifacts.sort((left, right) =>
|
||||
String(right.lastAccessedAt).localeCompare(String(left.lastAccessedAt)));
|
||||
return { totalBytes, count: artifacts.length, artifacts, errors };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const EXTENSION_ROOT = dirname(fileURLToPath(import.meta.url));
|
||||
export const ASSET_ROOT = join(EXTENSION_ROOT, "assets");
|
||||
|
||||
const copilotHome = process.env.COPILOT_HOME || join(homedir(), ".copilot");
|
||||
export const DATA_ROOT = join(copilotHome, "extensions", "pr-artifact-explorer", "artifacts");
|
||||
export const CACHE_ROOT = join(DATA_ROOT, "cache");
|
||||
export const PREFS_FILE = join(DATA_ROOT, "preferences.json");
|
||||
|
||||
export const GITHUB_API = "https://api.github.com";
|
||||
export const USER_AGENT = "github-copilot-pr-artifact-explorer";
|
||||
|
||||
// The indexer supports standard ZIP archives up to the ZIP32 boundary.
|
||||
export const MAX_ARCHIVE_BYTES = (4 * 1024 * 1024 * 1024) - 2;
|
||||
export const MIN_CACHE_FREE_BYTES = 512 * 1024 * 1024;
|
||||
export const ARTIFACT_DOWNLOAD_IDLE_TIMEOUT_MS = 60_000;
|
||||
export const MAX_CENTRAL_DIRECTORY_BYTES = 64 * 1024 * 1024;
|
||||
export const MAX_ZIP_ENTRIES = 50_000;
|
||||
export const MAX_INLINE_PREVIEW_BYTES = 4 * 1024 * 1024;
|
||||
export const MAX_TRX_PREVIEW_BYTES = 32 * 1024 * 1024;
|
||||
export const MAX_STREAMED_ENTRY_BYTES = 512 * 1024 * 1024;
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "pr-artifact-explorer",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { posix } from "node:path";
|
||||
|
||||
const MIME_TYPES = Object.freeze({
|
||||
".avif": "image/avif",
|
||||
".bmp": "image/bmp",
|
||||
".cast": "application/x-asciicast",
|
||||
".config": "application/xml; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".csproj": "application/xml; charset=utf-8",
|
||||
".csv": "text/csv; charset=utf-8",
|
||||
".gif": "image/gif",
|
||||
".htm": "text/html; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".ico": "image/x-icon",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".log": "text/plain; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".md": "text/markdown; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".nuspec": "application/xml; charset=utf-8",
|
||||
".pdf": "application/pdf",
|
||||
".png": "image/png",
|
||||
".props": "application/xml; charset=utf-8",
|
||||
".resx": "application/xml; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".text": "text/plain; charset=utf-8",
|
||||
".toml": "text/plain; charset=utf-8",
|
||||
".ts": "text/plain; charset=utf-8",
|
||||
".tsx": "text/plain; charset=utf-8",
|
||||
".targets": "application/xml; charset=utf-8",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".trx": "application/xml; charset=utf-8",
|
||||
".webp": "image/webp",
|
||||
".xaml": "application/xml; charset=utf-8",
|
||||
".xml": "application/xml; charset=utf-8",
|
||||
".yaml": "text/yaml; charset=utf-8",
|
||||
".yml": "text/yaml; charset=utf-8",
|
||||
".zip": "application/zip",
|
||||
});
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
".config", ".css", ".csproj", ".csv", ".htm", ".html", ".ini", ".js",
|
||||
".json", ".log", ".map", ".md", ".mjs", ".nuspec", ".props", ".ps1",
|
||||
".resx", ".sh", ".targets", ".text", ".toml", ".ts", ".tsx", ".txt",
|
||||
".trx", ".xaml", ".xml", ".yaml", ".yml",
|
||||
]);
|
||||
|
||||
const XML_EXTENSIONS = new Set([
|
||||
".config", ".csproj", ".nuspec", ".props", ".resx", ".targets", ".xaml",
|
||||
".trx", ".xml",
|
||||
]);
|
||||
|
||||
export function extensionOf(path) {
|
||||
const base = posix.basename(path);
|
||||
const index = base.lastIndexOf(".");
|
||||
return index > 0 ? base.slice(index).toLowerCase() : "";
|
||||
}
|
||||
|
||||
export function mimeForPath(path) {
|
||||
return MIME_TYPES[extensionOf(path)] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
export function kindForPath(path) {
|
||||
const extension = extensionOf(path);
|
||||
if (extension === ".cast") return "asciinema";
|
||||
if (extension === ".trx") return "trx";
|
||||
if (extension === ".html" || extension === ".htm") return "html";
|
||||
if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".bmp", ".svg", ".ico"].includes(extension)) {
|
||||
return "image";
|
||||
}
|
||||
if (extension === ".pdf") return "pdf";
|
||||
if (extension === ".md") return "markdown";
|
||||
if (extension === ".json" || extension === ".map") return "json";
|
||||
if (XML_EXTENSIONS.has(extension)) return "xml";
|
||||
if (TEXT_EXTENSIONS.has(extension)) return "text";
|
||||
if ([".zip", ".gz", ".tgz", ".7z", ".tar", ".rar"].includes(extension)) return "archive";
|
||||
return "binary";
|
||||
}
|
||||
|
||||
export function hasRootIndexHtml(entries) {
|
||||
return entries.some((entry) => {
|
||||
if (!entry?.supported) return false;
|
||||
const path = String(entry?.name ?? entry?.path ?? "")
|
||||
.replaceAll("\\", "/")
|
||||
.replace(/^\.\/+/, "");
|
||||
return path.toLocaleLowerCase() === "index.html";
|
||||
});
|
||||
}
|
||||
|
||||
function shallowest(entries) {
|
||||
return [...entries].sort((left, right) => {
|
||||
const depth = left.name.split("/").length - right.name.split("/").length;
|
||||
return depth || left.name.localeCompare(right.name);
|
||||
})[0] ?? null;
|
||||
}
|
||||
|
||||
async function looksLikeAsciinema(entry, readHead) {
|
||||
if (kindForPath(entry.name) === "asciinema") return true;
|
||||
if (
|
||||
entry.uncompressedSize > 8 * 1024 * 1024 ||
|
||||
!/(recording|asciinema|terminal|session)/i.test(posix.basename(entry.name))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
let head;
|
||||
try {
|
||||
head = await readHead(entry);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!head) return false;
|
||||
const firstLine = head.toString("utf8").split(/\r?\n/, 1)[0].trim();
|
||||
if (!firstLine.startsWith("{")) return false;
|
||||
try {
|
||||
const header = JSON.parse(firstLine);
|
||||
return (
|
||||
(header.version === 2 || header.version === 3) &&
|
||||
Number.isFinite(header.width) &&
|
||||
Number.isFinite(header.height)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function analyzeArtifact(index, readHead) {
|
||||
const files = index.entries.filter((entry) => !entry.directory);
|
||||
const previewableFiles = files.filter((entry) => entry.supported);
|
||||
const castEntries = [];
|
||||
for (const entry of previewableFiles.slice(0, 2_000)) {
|
||||
if (await looksLikeAsciinema(entry, readHead)) castEntries.push(entry);
|
||||
}
|
||||
|
||||
const htmlEntries = previewableFiles.filter(
|
||||
(entry) => kindForPath(entry.name) === "html",
|
||||
);
|
||||
const indexEntries = htmlEntries.filter(
|
||||
(entry) => posix.basename(entry.name).toLowerCase() === "index.html",
|
||||
);
|
||||
const rootIndexEntry = indexEntries.find(
|
||||
(entry) => hasRootIndexHtml([entry]),
|
||||
);
|
||||
const firstTrx = shallowest(
|
||||
previewableFiles.filter((entry) => kindForPath(entry.name) === "trx"),
|
||||
);
|
||||
const firstImage = shallowest(
|
||||
previewableFiles.filter((entry) => kindForPath(entry.name) === "image"),
|
||||
);
|
||||
const firstMarkdown = shallowest(
|
||||
previewableFiles.filter((entry) => kindForPath(entry.name) === "markdown"),
|
||||
);
|
||||
const firstText = shallowest(
|
||||
previewableFiles.filter((entry) =>
|
||||
["json", "text", "xml"].includes(kindForPath(entry.name)),
|
||||
),
|
||||
);
|
||||
|
||||
let primary = null;
|
||||
if (rootIndexEntry) {
|
||||
primary = {
|
||||
kind: "static-site",
|
||||
path: rootIndexEntry.name,
|
||||
root: "",
|
||||
label: "Static HTML site",
|
||||
};
|
||||
} else if (castEntries.length) {
|
||||
primary = {
|
||||
kind: "asciinema",
|
||||
path: castEntries[0].name,
|
||||
label: castEntries.length === 1 ? "Asciinema recording" : `${castEntries.length} Asciinema recordings`,
|
||||
};
|
||||
} else if (firstTrx) {
|
||||
primary = { kind: "trx", path: firstTrx.name, label: "Test results" };
|
||||
} else if (firstImage) {
|
||||
primary = { kind: "image", path: firstImage.name, label: "Image preview" };
|
||||
} else if (firstMarkdown) {
|
||||
primary = { kind: "markdown", path: firstMarkdown.name, label: "Markdown document" };
|
||||
} else if (firstText) {
|
||||
const kind = kindForPath(firstText.name);
|
||||
primary = {
|
||||
kind,
|
||||
path: firstText.name,
|
||||
label: kind === "xml" ? "XML code" : kind === "json" ? "JSON code" : "Text preview",
|
||||
};
|
||||
}
|
||||
|
||||
const counts = {};
|
||||
const entries = files.map((entry) => {
|
||||
const detectedKind = castEntries.includes(entry) ? "asciinema" : kindForPath(entry.name);
|
||||
counts[detectedKind] = (counts[detectedKind] ?? 0) + 1;
|
||||
return {
|
||||
path: entry.name,
|
||||
name: posix.basename(entry.name),
|
||||
size: entry.uncompressedSize,
|
||||
compressedSize: entry.compressedSize,
|
||||
kind: detectedKind,
|
||||
mime: detectedKind === "asciinema" ? "application/x-asciicast" : mimeForPath(entry.name),
|
||||
supported: entry.supported,
|
||||
modifiedAt: entry.modifiedAt,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
primary,
|
||||
entries,
|
||||
counts,
|
||||
fileCount: files.length,
|
||||
totalUncompressedBytes: index.totalUncompressedBytes,
|
||||
zipBacked: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function isInlineTextKind(kind) {
|
||||
return ["json", "markdown", "text", "trx", "xml"].includes(kind);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
CanvasError,
|
||||
createCanvas,
|
||||
joinSession,
|
||||
} from "@github/copilot-sdk/extension";
|
||||
import { resolveAccounts, invalidateAccounts } from "./accounts.mjs";
|
||||
import {
|
||||
clearArtifactCache,
|
||||
deleteCachedArtifact,
|
||||
getCacheSummary,
|
||||
inspectArtifact,
|
||||
} from "./cache.mjs";
|
||||
import {
|
||||
broadcastCache,
|
||||
closeAllPreviews,
|
||||
navigateInstance,
|
||||
startInstance,
|
||||
stopInstance,
|
||||
} from "./server.mjs";
|
||||
import { stopStaticPreviewsForArtifact } from "./preview.mjs";
|
||||
import { loadPrefs, normalizeRepository, savePrefs } from "./state.mjs";
|
||||
|
||||
function positiveInteger(value, label) {
|
||||
const number = Number.parseInt(value, 10);
|
||||
if (!Number.isSafeInteger(number) || number <= 0) {
|
||||
throw new CanvasError("invalid_input", `${label} must be a positive integer.`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
const session = await joinSession({
|
||||
canvases: [
|
||||
createCanvas({
|
||||
id: "pr-artifact-explorer",
|
||||
displayName: "Artifact Explorer",
|
||||
description:
|
||||
"Browse GitHub pull requests and securely preview workflow artifacts, including static sites and asciinema recordings.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
repository: {
|
||||
type: "string",
|
||||
description: "Repository in owner/name format.",
|
||||
},
|
||||
pullNumber: {
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
description: "Pull request number to open.",
|
||||
},
|
||||
url: {
|
||||
type: "string",
|
||||
description: "GitHub pull request URL to open.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
name: "open_pull_request",
|
||||
description: "Navigate an open explorer canvas to a pull request.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
repository: { type: "string" },
|
||||
pullNumber: { type: "integer", minimum: 1 },
|
||||
},
|
||||
required: ["repository", "pullNumber"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler: (ctx) => {
|
||||
const repository = normalizeRepository(ctx.input?.repository);
|
||||
const pullNumber = positiveInteger(ctx.input?.pullNumber, "pullNumber");
|
||||
const route = `#/pull/${encodeURIComponent(repository)}/${pullNumber}`;
|
||||
return {
|
||||
navigated: navigateInstance(ctx.instanceId, route),
|
||||
repository,
|
||||
pullNumber,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inspect_artifact",
|
||||
description: "Download, index, and smart-detect a GitHub Actions artifact.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
repository: { type: "string" },
|
||||
artifactId: { type: "integer", minimum: 1 },
|
||||
},
|
||||
required: ["repository", "artifactId"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler: async (ctx) => {
|
||||
const repository = normalizeRepository(ctx.input?.repository);
|
||||
const artifactId = positiveInteger(ctx.input?.artifactId, "artifactId");
|
||||
const prefs = await loadPrefs();
|
||||
const auth = await resolveAccounts({
|
||||
preferredId: prefs.account,
|
||||
repository,
|
||||
});
|
||||
if (!auth.activeToken) {
|
||||
throw new CanvasError("github_auth_required", "No usable GitHub account was found.");
|
||||
}
|
||||
const metadata = await inspectArtifact(auth.activeToken, repository, artifactId);
|
||||
await broadcastCache();
|
||||
navigateInstance(
|
||||
ctx.instanceId,
|
||||
`#/artifact/${encodeURIComponent(repository)}/0/${artifactId}`,
|
||||
);
|
||||
const entries = metadata.analysis.entries ?? [];
|
||||
return {
|
||||
artifact: metadata.artifact,
|
||||
analysis: {
|
||||
...metadata.analysis,
|
||||
entries: entries.slice(0, 200),
|
||||
entriesTruncated: entries.length > 200,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cache_status",
|
||||
description: "Report downloaded artifact cache usage.",
|
||||
handler: async () => getCacheSummary(),
|
||||
},
|
||||
{
|
||||
name: "clear_cache",
|
||||
description: "Delete one downloaded artifact or clear the entire artifact cache.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
artifactId: { type: "integer", minimum: 1 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler: async (ctx) => {
|
||||
if (ctx.input?.artifactId != null) {
|
||||
await stopStaticPreviewsForArtifact(ctx.input.artifactId);
|
||||
const result = await deleteCachedArtifact(
|
||||
positiveInteger(ctx.input.artifactId, "artifactId"),
|
||||
);
|
||||
navigateInstance(ctx.instanceId, "#/cache");
|
||||
await broadcastCache({ refresh: true });
|
||||
return result;
|
||||
}
|
||||
await closeAllPreviews();
|
||||
const result = await clearArtifactCache();
|
||||
navigateInstance(ctx.instanceId, "#/cache");
|
||||
await broadcastCache({ refresh: true });
|
||||
return result;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounts",
|
||||
description: "List GitHub accounts inherited from the Copilot app and GitHub CLI.",
|
||||
handler: async () => {
|
||||
const prefs = await loadPrefs();
|
||||
const auth = await resolveAccounts({
|
||||
preferredId: prefs.account,
|
||||
repository: prefs.repository,
|
||||
force: true,
|
||||
});
|
||||
return { active: auth.active, accounts: auth.accounts };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set_account",
|
||||
description: "Select the GitHub account used by the artifact explorer.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" } },
|
||||
required: ["id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler: async (ctx) => {
|
||||
if (typeof ctx.input?.id !== "string" || !ctx.input.id) {
|
||||
throw new CanvasError("invalid_account", "Account id is required.");
|
||||
}
|
||||
const prefs = await loadPrefs();
|
||||
invalidateAccounts();
|
||||
const auth = await resolveAccounts({
|
||||
preferredId: ctx.input.id,
|
||||
repository: prefs.repository,
|
||||
force: true,
|
||||
});
|
||||
if (!auth.active || auth.active.id !== ctx.input.id) {
|
||||
throw new CanvasError(
|
||||
"invalid_account",
|
||||
`Account "${ctx.input.id}" was not found or is unavailable.`,
|
||||
);
|
||||
}
|
||||
prefs.account = ctx.input.id;
|
||||
await savePrefs(prefs);
|
||||
return { active: auth.active, accounts: auth.accounts };
|
||||
},
|
||||
},
|
||||
],
|
||||
open: async (ctx) => {
|
||||
const entry = await startInstance(
|
||||
ctx.instanceId,
|
||||
ctx.input,
|
||||
(message) => session.log(message, { level: "debug" }),
|
||||
);
|
||||
return {
|
||||
title: "Artifact Explorer",
|
||||
status: "GitHub pull request artifacts",
|
||||
url: entry.url,
|
||||
};
|
||||
},
|
||||
onClose: async (ctx) => {
|
||||
await stopInstance(ctx.instanceId);
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,712 @@
|
||||
import { GITHUB_API, USER_AGENT } from "./constants.mjs";
|
||||
import { normalizeRepository } from "./state.mjs";
|
||||
|
||||
const PULL_ARTIFACT_FILTERS = new Set(["all", "with", "without"]);
|
||||
const PULL_CI_FILTERS = new Set(["all", "failing", "passing", "pending", "none"]);
|
||||
const ARTIFACT_PRESENCE_TTL_MS = 5 * 60 * 1_000;
|
||||
const MISSING_ARTIFACT_TTL_MS = 30 * 1_000;
|
||||
const ARTIFACT_PRESENCE_CACHE_LIMIT = 1_000;
|
||||
// Each run fans out into paginated artifact requests, so keep the initial view bounded.
|
||||
const MAX_PULL_REQUEST_RUNS = 30;
|
||||
const MAX_PULL_REQUEST_ARTIFACTS = 2_000;
|
||||
const artifactPresenceCache = new Map();
|
||||
|
||||
export class GitHubApiError extends Error {
|
||||
constructor(message, status, details = null) {
|
||||
super(message);
|
||||
this.name = "GitHubApiError";
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function repositoryPath(repository) {
|
||||
return normalizeRepository(repository).split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
function headersFor(token, extra = {}) {
|
||||
return {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"User-Agent": USER_AGENT,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function readPayload(response) {
|
||||
const text = await response.text();
|
||||
if (!text) return null;
|
||||
const type = response.headers.get("content-type") ?? "";
|
||||
if (type.includes("json")) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
throw new GitHubApiError("GitHub returned malformed JSON.", response.status, {
|
||||
cause: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export async function githubRequest(token, path, options = {}) {
|
||||
if (!token) throw new GitHubApiError("No GitHub account is selected.", 401);
|
||||
if (!path.startsWith("/")) throw new Error("GitHub API paths must be absolute.");
|
||||
|
||||
const response = await fetch(`${GITHUB_API}${path}`, {
|
||||
method: options.method ?? "GET",
|
||||
headers: headersFor(token, options.headers),
|
||||
body: options.body,
|
||||
signal: options.signal ?? AbortSignal.timeout(30_000),
|
||||
});
|
||||
const payload = await readPayload(response);
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
payload && typeof payload === "object" && payload.message
|
||||
? payload.message
|
||||
: `GitHub API request failed (${response.status}).`;
|
||||
throw new GitHubApiError(message, response.status, payload);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function repositorySummary(repository) {
|
||||
return {
|
||||
fullName: repository.full_name,
|
||||
name: repository.name,
|
||||
owner: {
|
||||
login: repository.owner?.login ?? "",
|
||||
avatarUrl: repository.owner?.avatar_url ?? null,
|
||||
type: repository.owner?.type ?? null,
|
||||
},
|
||||
description: repository.description ?? null,
|
||||
private: repository.private === true,
|
||||
archived: repository.archived === true,
|
||||
visibility: repository.visibility ?? (repository.private ? "private" : "public"),
|
||||
url: repository.html_url,
|
||||
updatedAt: repository.updated_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRepository(token, repository) {
|
||||
return repositorySummary(
|
||||
await githubRequest(token, `/repos/${repositoryPath(repository)}`),
|
||||
);
|
||||
}
|
||||
|
||||
async function contributorProfilesByNodeId(token, contributors) {
|
||||
const ids = contributors
|
||||
.map((contributor) => contributor.node_id)
|
||||
.filter(Boolean);
|
||||
if (!ids.length) return new Map();
|
||||
|
||||
const payload = await githubRequest(token, "/graphql", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query: `
|
||||
query ContributorProfiles($ids: [ID!]!) {
|
||||
nodes(ids: $ids) {
|
||||
id
|
||||
... on User {
|
||||
login
|
||||
name
|
||||
}
|
||||
... on Bot {
|
||||
login
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { ids },
|
||||
}),
|
||||
});
|
||||
if (payload?.errors?.length) {
|
||||
throw new GitHubApiError(
|
||||
payload.errors[0]?.message ?? "GitHub could not load contributor profiles.",
|
||||
502,
|
||||
payload.errors,
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(payload?.data?.nodes)) {
|
||||
throw new GitHubApiError("GitHub returned invalid contributor profile data.", 502, payload);
|
||||
}
|
||||
|
||||
return new Map(
|
||||
payload.data.nodes
|
||||
.filter((profile) => profile?.id)
|
||||
.map((profile) => [profile.id, profile]),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listRepositoryContributors(token, repository) {
|
||||
const contributors = (
|
||||
await githubRequest(
|
||||
token,
|
||||
`/repos/${repositoryPath(repository)}/contributors?anon=0&per_page=100`,
|
||||
)
|
||||
).filter((contributor) => contributor?.login);
|
||||
const profilesById = await contributorProfilesByNodeId(token, contributors);
|
||||
|
||||
return contributors
|
||||
.map((contributor) => ({
|
||||
login: contributor.login,
|
||||
name:
|
||||
typeof profilesById.get(contributor.node_id)?.name === "string"
|
||||
? profilesById.get(contributor.node_id).name.trim() || null
|
||||
: null,
|
||||
avatarUrl: contributor.avatar_url ?? null,
|
||||
profileUrl: contributor.html_url ?? null,
|
||||
type: contributor.type ?? null,
|
||||
contributions: Number(contributor.contributions) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function suggestRepositories(token, query) {
|
||||
const value = String(query ?? "").trim();
|
||||
if (!value || value.length > 100) return [];
|
||||
|
||||
const slash = value.indexOf("/");
|
||||
if (slash > 0) {
|
||||
const owner = value.slice(0, slash).trim();
|
||||
const partial = value.slice(slash + 1).trim();
|
||||
if (!/^[A-Za-z0-9_.-]+$/.test(owner)) return [];
|
||||
|
||||
const profile = await githubRequest(token, `/users/${encodeURIComponent(owner)}`);
|
||||
if (partial) {
|
||||
const qualifier = profile.type === "Organization" ? "org" : "user";
|
||||
const result = await githubRequest(
|
||||
token,
|
||||
`/search/repositories?q=${encodeURIComponent(`${partial} in:name ${qualifier}:${owner}`)}&sort=updated&order=desc&per_page=12`,
|
||||
);
|
||||
return (result.items ?? []).map(repositorySummary);
|
||||
}
|
||||
|
||||
const endpoint =
|
||||
profile.type === "Organization"
|
||||
? `/orgs/${encodeURIComponent(owner)}/repos?type=all&sort=updated&direction=desc&per_page=20`
|
||||
: `/users/${encodeURIComponent(owner)}/repos?type=all&sort=updated&direction=desc&per_page=20`;
|
||||
return (await githubRequest(token, endpoint)).map(repositorySummary);
|
||||
}
|
||||
|
||||
if (value.length < 2) return [];
|
||||
const result = await githubRequest(
|
||||
token,
|
||||
`/search/repositories?q=${encodeURIComponent(`${value} in:name`)}&sort=updated&order=desc&per_page=12`,
|
||||
);
|
||||
return (result.items ?? []).map(repositorySummary);
|
||||
}
|
||||
|
||||
function mapPullRequest(item) {
|
||||
return {
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
state: item.state,
|
||||
draft: Boolean(item.draft),
|
||||
url: item.html_url,
|
||||
author: item.user?.login ?? "ghost",
|
||||
authorAvatarUrl: item.user?.avatar_url ?? null,
|
||||
authorType: item.user?.type ?? null,
|
||||
authorAssociation: item.author_association ?? null,
|
||||
comments: Number(item.comments) || 0,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
closedAt: item.closed_at,
|
||||
mergedAt: item.merged_at ?? null,
|
||||
headSha: item.head?.sha ?? null,
|
||||
headRef: item.head?.ref ?? null,
|
||||
baseRef: item.base?.ref ?? null,
|
||||
labels: (item.labels ?? []).map((label) => ({
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
description: label.description ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function mapSearchPullRequest(item) {
|
||||
return {
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
state: item.state,
|
||||
draft: Boolean(item.draft),
|
||||
url: item.html_url,
|
||||
author: item.user?.login ?? "ghost",
|
||||
authorAvatarUrl: item.user?.avatar_url ?? null,
|
||||
authorType: item.user?.type ?? null,
|
||||
authorAssociation: item.author_association ?? null,
|
||||
comments: Number(item.comments) || 0,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
closedAt: item.closed_at,
|
||||
mergedAt: item.pull_request?.merged_at ?? null,
|
||||
headSha: null,
|
||||
headRef: null,
|
||||
baseRef: null,
|
||||
labels: (item.labels ?? []).map((label) => ({
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
description: label.description ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePullFilters(filters = {}) {
|
||||
return {
|
||||
artifacts: PULL_ARTIFACT_FILTERS.has(filters.artifacts) ? filters.artifacts : "all",
|
||||
ci: PULL_CI_FILTERS.has(filters.ci) ? filters.ci : "all",
|
||||
};
|
||||
}
|
||||
|
||||
function ciStatusForRollup(state) {
|
||||
if (state === "FAILURE" || state === "ERROR") return "failing";
|
||||
if (state === "SUCCESS") return "passing";
|
||||
if (state === "PENDING" || state === "EXPECTED") return "pending";
|
||||
return "none";
|
||||
}
|
||||
|
||||
async function pullRequestSignals(token, repository, pulls) {
|
||||
if (!pulls.length) return new Map();
|
||||
const [owner, name] = normalizeRepository(repository).split("/");
|
||||
const selections = pulls
|
||||
.map(
|
||||
(pull, index) => `
|
||||
pull${index}: pullRequest(number: ${Number(pull.number)}) {
|
||||
number
|
||||
isDraft
|
||||
headRefOid
|
||||
reviewDecision
|
||||
totalCommentsCount
|
||||
commits(last: 1) {
|
||||
nodes {
|
||||
commit {
|
||||
statusCheckRollup {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
)
|
||||
.join("\n");
|
||||
const payload = await githubRequest(token, "/graphql", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query: `
|
||||
query PullRequestSignals($owner: String!, $name: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
${selections}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { owner, name },
|
||||
}),
|
||||
});
|
||||
if (payload?.errors?.length) {
|
||||
throw new GitHubApiError(
|
||||
payload.errors[0]?.message ?? "GitHub could not load pull request signals.",
|
||||
502,
|
||||
payload.errors,
|
||||
);
|
||||
}
|
||||
const result = payload?.data?.repository;
|
||||
if (!result || typeof result !== "object") {
|
||||
throw new GitHubApiError("GitHub returned invalid pull request signal data.", 502, payload);
|
||||
}
|
||||
|
||||
return new Map(
|
||||
pulls.map((pull, index) => {
|
||||
const signal = result[`pull${index}`];
|
||||
if (!signal) {
|
||||
throw new GitHubApiError(
|
||||
`GitHub did not return signal data for pull request #${pull.number}.`,
|
||||
502,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
const rollupState =
|
||||
signal.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state ?? null;
|
||||
return [
|
||||
Number(pull.number),
|
||||
{
|
||||
draft: Boolean(signal.isDraft),
|
||||
headSha: signal.headRefOid ?? null,
|
||||
ciStatus: ciStatusForRollup(rollupState),
|
||||
reviewDecision: signal.reviewDecision ?? null,
|
||||
comments: Number(signal.totalCommentsCount) || 0,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function findArtifactHeads(token, repository) {
|
||||
const heads = new Set();
|
||||
let artifactCount = 0;
|
||||
for (let page = 1; ; page++) {
|
||||
const payload = await githubRequest(
|
||||
token,
|
||||
`/repos/${repositoryPath(repository)}/actions/artifacts?per_page=100&page=${page}`,
|
||||
);
|
||||
const artifacts = payload.artifacts ?? [];
|
||||
artifactCount += artifacts.length;
|
||||
for (const artifact of artifacts) {
|
||||
const headSha = artifact.workflow_run?.head_sha;
|
||||
if (headSha) heads.add(headSha);
|
||||
}
|
||||
const totalCount = Number(payload.total_count) || 0;
|
||||
if (artifacts.length < 100 || artifactCount >= totalCount) return heads;
|
||||
}
|
||||
}
|
||||
|
||||
function pruneArtifactPresenceCache() {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of artifactPresenceCache) {
|
||||
if (entry.positiveExpiresAt <= now) artifactPresenceCache.delete(key);
|
||||
}
|
||||
while (artifactPresenceCache.size >= ARTIFACT_PRESENCE_CACHE_LIMIT) {
|
||||
artifactPresenceCache.delete(artifactPresenceCache.keys().next().value);
|
||||
}
|
||||
}
|
||||
|
||||
async function artifactHeadsForPulls(token, repository, headShas) {
|
||||
const requestedHeads = new Set(headShas.filter(Boolean));
|
||||
if (!requestedHeads.size) return new Set();
|
||||
const key = normalizeRepository(repository).toLocaleLowerCase();
|
||||
const now = Date.now();
|
||||
const cached = artifactPresenceCache.get(key);
|
||||
if (cached?.missingExpiresAt > now) return cached.promise;
|
||||
if (cached?.positiveExpiresAt > now) {
|
||||
const cachedHeads = await cached.promise;
|
||||
if ([...requestedHeads].every((headSha) => cachedHeads.has(headSha))) {
|
||||
return cachedHeads;
|
||||
}
|
||||
}
|
||||
|
||||
pruneArtifactPresenceCache();
|
||||
const entry = {
|
||||
missingExpiresAt: now + MISSING_ARTIFACT_TTL_MS,
|
||||
positiveExpiresAt: now + ARTIFACT_PRESENCE_TTL_MS,
|
||||
promise: findArtifactHeads(token, repository),
|
||||
};
|
||||
artifactPresenceCache.set(key, entry);
|
||||
try {
|
||||
return await entry.promise;
|
||||
} catch (error) {
|
||||
if (artifactPresenceCache.get(key) === entry) {
|
||||
artifactPresenceCache.delete(key);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPullRequests(token, repository, state = "open") {
|
||||
const normalizedState = ["open", "closed", "all"].includes(state) ? state : "open";
|
||||
const query = new URLSearchParams({
|
||||
state: normalizedState,
|
||||
sort: "updated",
|
||||
direction: "desc",
|
||||
per_page: "50",
|
||||
});
|
||||
const payload = await githubRequest(
|
||||
token,
|
||||
`/repos/${repositoryPath(repository)}/pulls?${query}`,
|
||||
);
|
||||
return payload.map(mapPullRequest);
|
||||
}
|
||||
|
||||
function normalizePullSearchQuery(filter, state) {
|
||||
const normalizedState = ["open", "closed", "all"].includes(state) ? state : "open";
|
||||
let query = String(filter ?? "")
|
||||
.trim()
|
||||
.slice(0, 500)
|
||||
.replace(/(^|\s)(?:repo|org|user):(?:"[^"]+"|\S+)/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (!/\bis:pr\b/i.test(query)) query = `is:pr ${query}`.trim();
|
||||
if (
|
||||
normalizedState !== "all" &&
|
||||
!/\bis:(?:open|closed|merged|unmerged)\b/i.test(query)
|
||||
) {
|
||||
query = `${query} is:${normalizedState}`;
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
export async function searchPullRequestBase(
|
||||
token,
|
||||
repository,
|
||||
filter = "",
|
||||
state = "open",
|
||||
filters = {},
|
||||
{ perPage = 100 } = {},
|
||||
) {
|
||||
const normalizedFilters = normalizePullFilters(filters);
|
||||
const query = normalizePullSearchQuery(filter, state);
|
||||
const normalizedPerPage = Math.max(1, Math.min(100, Number(perPage) || 100));
|
||||
const parameters = new URLSearchParams({
|
||||
q: `repo:${normalizeRepository(repository)} ${query}`,
|
||||
sort: "updated",
|
||||
order: "desc",
|
||||
per_page: String(normalizedPerPage),
|
||||
});
|
||||
const payload = await githubRequest(token, `/search/issues?${parameters}`);
|
||||
const sourcePulls = (payload.items ?? []).map(mapSearchPullRequest);
|
||||
return {
|
||||
query,
|
||||
totalCount: Number(payload.total_count) || 0,
|
||||
incomplete: payload.incomplete_results === true,
|
||||
filters: normalizedFilters,
|
||||
evaluatedCount: sourcePulls.length,
|
||||
filtered: false,
|
||||
pulls: sourcePulls,
|
||||
};
|
||||
}
|
||||
|
||||
export async function enrichPullRequests(token, repository, pulls, filters = {}) {
|
||||
const normalizedFilters = normalizePullFilters(filters);
|
||||
let enriched = pulls;
|
||||
if (pulls.length) {
|
||||
const signals = await pullRequestSignals(token, repository, pulls);
|
||||
enriched = pulls.map((pull) => ({
|
||||
...pull,
|
||||
...signals.get(Number(pull.number)),
|
||||
}));
|
||||
}
|
||||
if (normalizedFilters.artifacts !== "all") {
|
||||
const artifactHeads = await artifactHeadsForPulls(
|
||||
token,
|
||||
repository,
|
||||
enriched.map((pull) => pull.headSha),
|
||||
);
|
||||
enriched = enriched.map((pull) => ({
|
||||
...pull,
|
||||
hasArtifacts: Boolean(pull.headSha && artifactHeads.has(pull.headSha)),
|
||||
}));
|
||||
}
|
||||
return enriched;
|
||||
}
|
||||
|
||||
export function filterPullRequests(pulls, filters = {}) {
|
||||
const normalizedFilters = normalizePullFilters(filters);
|
||||
let filtered = pulls;
|
||||
if (normalizedFilters.ci !== "all") {
|
||||
filtered = filtered.filter((pull) => pull.ciStatus === normalizedFilters.ci);
|
||||
}
|
||||
if (normalizedFilters.artifacts !== "all") {
|
||||
const expected = normalizedFilters.artifacts === "with";
|
||||
filtered = filtered.filter((pull) => pull.hasArtifacts === expected);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export async function searchPullRequests(
|
||||
token,
|
||||
repository,
|
||||
filter = "",
|
||||
state = "open",
|
||||
filters = {},
|
||||
) {
|
||||
const base = await searchPullRequestBase(
|
||||
token,
|
||||
repository,
|
||||
filter,
|
||||
state,
|
||||
filters,
|
||||
);
|
||||
const enriched = await enrichPullRequests(
|
||||
token,
|
||||
repository,
|
||||
base.pulls,
|
||||
base.filters,
|
||||
);
|
||||
const derivedFilterActive =
|
||||
base.filters.artifacts !== "all" || base.filters.ci !== "all";
|
||||
return {
|
||||
...base,
|
||||
filtered: derivedFilterActive,
|
||||
pulls: filterPullRequests(enriched, base.filters),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPullRequest(token, repository, pullNumber) {
|
||||
const number = Number.parseInt(pullNumber, 10);
|
||||
if (!Number.isSafeInteger(number) || number <= 0) {
|
||||
throw new Error("Pull request number must be a positive integer.");
|
||||
}
|
||||
return mapPullRequest(
|
||||
await githubRequest(token, `/repos/${repositoryPath(repository)}/pulls/${number}`),
|
||||
);
|
||||
}
|
||||
|
||||
async function mapLimit(items, limit, callback) {
|
||||
const results = new Array(items.length);
|
||||
let cursor = 0;
|
||||
async function worker() {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor++;
|
||||
results[index] = await callback(items[index], index);
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(limit, items.length) }, () => worker()),
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
async function listRunArtifacts(token, repository, runId) {
|
||||
const artifacts = [];
|
||||
for (let page = 1; page <= 10; page++) {
|
||||
const payload = await githubRequest(
|
||||
token,
|
||||
`/repos/${repositoryPath(repository)}/actions/runs/${runId}/artifacts?per_page=100&page=${page}`,
|
||||
);
|
||||
const pageArtifacts = payload.artifacts ?? [];
|
||||
artifacts.push(...pageArtifacts);
|
||||
if (pageArtifacts.length < 100 || artifacts.length >= Number(payload.total_count ?? 0)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
export async function listPullRequestArtifacts(token, repository, pullNumber) {
|
||||
const pullRequest = await getPullRequest(token, repository, pullNumber);
|
||||
if (!pullRequest.headSha) {
|
||||
return {
|
||||
pullRequest,
|
||||
runs: [],
|
||||
runCount: 0,
|
||||
runsTruncated: false,
|
||||
artifacts: [],
|
||||
artifactCount: 0,
|
||||
artifactsTruncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
const query = new URLSearchParams({
|
||||
head_sha: pullRequest.headSha,
|
||||
per_page: String(MAX_PULL_REQUEST_RUNS),
|
||||
});
|
||||
const runsPayload = await githubRequest(
|
||||
token,
|
||||
`/repos/${repositoryPath(repository)}/actions/runs?${query}`,
|
||||
);
|
||||
const returnedRuns = (runsPayload.workflow_runs ?? [])
|
||||
.sort((left, right) => new Date(right.created_at) - new Date(left.created_at));
|
||||
const runCount = Math.max(
|
||||
returnedRuns.length,
|
||||
Number(runsPayload.total_count) || 0,
|
||||
);
|
||||
const runs = returnedRuns.slice(0, MAX_PULL_REQUEST_RUNS);
|
||||
const runsTruncated = runs.length < runCount;
|
||||
|
||||
const artifactGroups = await mapLimit(runs, 5, async (run) => {
|
||||
const runArtifacts = await listRunArtifacts(token, repository, run.id);
|
||||
return runArtifacts.map((artifact) => ({
|
||||
id: artifact.id,
|
||||
name: artifact.name,
|
||||
sizeInBytes: artifact.size_in_bytes,
|
||||
expired: Boolean(artifact.expired),
|
||||
createdAt: artifact.created_at,
|
||||
updatedAt: artifact.updated_at,
|
||||
expiresAt: artifact.expires_at,
|
||||
run: {
|
||||
id: run.id,
|
||||
name: run.name,
|
||||
number: run.run_number,
|
||||
attempt: run.run_attempt,
|
||||
event: run.event,
|
||||
status: run.status,
|
||||
conclusion: run.conclusion,
|
||||
createdAt: run.created_at,
|
||||
url: run.html_url,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
const allArtifacts = [...new Map(
|
||||
artifactGroups.flat().map((artifact) => [String(artifact.id), artifact]),
|
||||
).values()].sort((left, right) => new Date(right.createdAt) - new Date(left.createdAt));
|
||||
const artifacts = allArtifacts.slice(0, MAX_PULL_REQUEST_ARTIFACTS);
|
||||
|
||||
return {
|
||||
pullRequest,
|
||||
runs: runs.map((run) => ({
|
||||
id: run.id,
|
||||
name: run.name,
|
||||
number: run.run_number,
|
||||
attempt: run.run_attempt,
|
||||
event: run.event,
|
||||
status: run.status,
|
||||
conclusion: run.conclusion,
|
||||
createdAt: run.created_at,
|
||||
url: run.html_url,
|
||||
})),
|
||||
runCount,
|
||||
runsTruncated,
|
||||
artifacts,
|
||||
artifactCount: allArtifacts.length,
|
||||
artifactsTruncated:
|
||||
runsTruncated || artifacts.length < allArtifacts.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getArtifact(token, repository, artifactId) {
|
||||
const id = Number.parseInt(artifactId, 10);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) {
|
||||
throw new Error("Artifact id must be a positive integer.");
|
||||
}
|
||||
const artifact = await githubRequest(
|
||||
token,
|
||||
`/repos/${repositoryPath(repository)}/actions/artifacts/${id}`,
|
||||
);
|
||||
return {
|
||||
id: artifact.id,
|
||||
name: artifact.name,
|
||||
sizeInBytes: artifact.size_in_bytes,
|
||||
expired: Boolean(artifact.expired),
|
||||
createdAt: artifact.created_at,
|
||||
updatedAt: artifact.updated_at,
|
||||
expiresAt: artifact.expires_at,
|
||||
run: artifact.workflow_run
|
||||
? {
|
||||
id: artifact.workflow_run.id,
|
||||
headBranch: artifact.workflow_run.head_branch,
|
||||
headSha: artifact.workflow_run.head_sha,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function openArtifactDownload(
|
||||
token,
|
||||
repository,
|
||||
artifactId,
|
||||
{ signal } = {},
|
||||
) {
|
||||
if (!token) throw new GitHubApiError("No GitHub account is selected.", 401);
|
||||
const id = Number.parseInt(artifactId, 10);
|
||||
const response = await fetch(
|
||||
`${GITHUB_API}/repos/${repositoryPath(repository)}/actions/artifacts/${id}/zip`,
|
||||
{
|
||||
headers: headersFor(token),
|
||||
redirect: "follow",
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const payload = await readPayload(response);
|
||||
const message =
|
||||
payload && typeof payload === "object" && payload.message
|
||||
? payload.message
|
||||
: `Artifact download failed (${response.status}).`;
|
||||
throw new GitHubApiError(message, response.status, payload);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new GitHubApiError("Artifact download returned an empty response.", response.status);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
export class ExpiringPromiseCache {
|
||||
#entries = new Map();
|
||||
#maxEntries;
|
||||
#ttlMs;
|
||||
|
||||
constructor({ maxEntries, ttlMs }) {
|
||||
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
||||
throw new TypeError("maxEntries must be a positive integer.");
|
||||
}
|
||||
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
||||
throw new TypeError("ttlMs must be a positive number.");
|
||||
}
|
||||
this.#maxEntries = maxEntries;
|
||||
this.#ttlMs = ttlMs;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.#entries.clear();
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
this.#entries.delete(String(key));
|
||||
}
|
||||
|
||||
peek(key) {
|
||||
const normalizedKey = String(key);
|
||||
const entry = this.#entries.get(normalizedKey);
|
||||
if (!entry?.hasValue || entry.expiresAt <= Date.now()) {
|
||||
if (!entry?.promise) this.#entries.delete(normalizedKey);
|
||||
return undefined;
|
||||
}
|
||||
this.#touch(normalizedKey, entry);
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
const normalizedKey = String(key);
|
||||
const entry = {
|
||||
expiresAt: Date.now() + this.#ttlMs,
|
||||
forceRefresh: false,
|
||||
hasValue: true,
|
||||
promise: null,
|
||||
value,
|
||||
};
|
||||
this.#entries.set(normalizedKey, entry);
|
||||
this.#touch(normalizedKey, entry);
|
||||
this.#enforceLimit();
|
||||
return value;
|
||||
}
|
||||
|
||||
async get(key, loader, { force = false } = {}) {
|
||||
if (typeof loader !== "function") throw new TypeError("loader must be a function.");
|
||||
const normalizedKey = String(key);
|
||||
const now = Date.now();
|
||||
let entry = this.#entries.get(normalizedKey);
|
||||
if (!force && entry?.hasValue && entry.expiresAt > now) {
|
||||
this.#touch(normalizedKey, entry);
|
||||
return entry.value;
|
||||
}
|
||||
if (entry?.promise && (!force || entry.forceRefresh)) return entry.promise;
|
||||
|
||||
this.#prune(now);
|
||||
if (force) entry = null;
|
||||
entry ??= {
|
||||
expiresAt: 0,
|
||||
forceRefresh: false,
|
||||
hasValue: false,
|
||||
promise: null,
|
||||
value: undefined,
|
||||
};
|
||||
const promise = Promise.resolve()
|
||||
.then(loader)
|
||||
.then(
|
||||
(value) => {
|
||||
if (
|
||||
this.#entries.get(normalizedKey) === entry &&
|
||||
entry.promise === promise
|
||||
) {
|
||||
entry.expiresAt = Date.now() + this.#ttlMs;
|
||||
entry.forceRefresh = false;
|
||||
entry.hasValue = true;
|
||||
entry.promise = null;
|
||||
entry.value = value;
|
||||
this.#touch(normalizedKey, entry);
|
||||
this.#enforceLimit();
|
||||
}
|
||||
return value;
|
||||
},
|
||||
(error) => {
|
||||
if (
|
||||
this.#entries.get(normalizedKey) === entry &&
|
||||
entry.promise === promise
|
||||
) {
|
||||
entry.forceRefresh = false;
|
||||
entry.promise = null;
|
||||
if (!entry.hasValue || entry.expiresAt <= Date.now()) {
|
||||
this.#entries.delete(normalizedKey);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
entry.forceRefresh = force;
|
||||
entry.promise = promise;
|
||||
this.#entries.set(normalizedKey, entry);
|
||||
this.#enforceLimit();
|
||||
return promise;
|
||||
}
|
||||
|
||||
#touch(key, entry) {
|
||||
this.#entries.delete(key);
|
||||
this.#entries.set(key, entry);
|
||||
}
|
||||
|
||||
#prune(now) {
|
||||
for (const [key, entry] of this.#entries) {
|
||||
if (!entry.promise && (!entry.hasValue || entry.expiresAt <= now)) {
|
||||
this.#entries.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#enforceLimit() {
|
||||
while (this.#entries.size > this.#maxEntries) {
|
||||
const candidate = [...this.#entries].find(([, entry]) => !entry.promise);
|
||||
if (!candidate) return;
|
||||
this.#entries.delete(candidate[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { createServer } from "node:http";
|
||||
import { posix } from "node:path";
|
||||
import { Transform } from "node:stream";
|
||||
import { getCachedEntry } from "./cache.mjs";
|
||||
import { mimeForPath } from "./detector.mjs";
|
||||
import { isCanonicalHost } from "./security.mjs";
|
||||
import { streamZipEntry } from "./zip.mjs";
|
||||
|
||||
const previewServers = new Map();
|
||||
const HTML_INJECTION_SEARCH_BYTES = 64 * 1024;
|
||||
|
||||
const PREVIEW_CSP = [
|
||||
"default-src 'self' data: blob:",
|
||||
"base-uri 'self'",
|
||||
"connect-src 'self'",
|
||||
"font-src 'self' data:",
|
||||
"form-action 'none'",
|
||||
"frame-src 'self' data: blob:",
|
||||
"img-src 'self' data: blob:",
|
||||
"media-src 'self' data: blob:",
|
||||
"object-src 'none'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob:",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"worker-src 'self' blob:",
|
||||
].join("; ");
|
||||
|
||||
function normalizeTheme(value) {
|
||||
if (value !== "light" && value !== "dark") {
|
||||
throw new Error("Preview theme must be light or dark.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeParentOrigin(value) {
|
||||
const origin = new URL(value);
|
||||
if (origin.protocol !== "http:" || origin.hostname !== "127.0.0.1") {
|
||||
throw new Error("Preview parent must use a loopback origin.");
|
||||
}
|
||||
return origin.origin;
|
||||
}
|
||||
|
||||
function themeBridgeMarkup(theme, parentOrigin) {
|
||||
const initialTheme = JSON.stringify(theme);
|
||||
const expectedOrigin = JSON.stringify(parentOrigin);
|
||||
return `<script data-copilot-preview-theme>(()=>{const expectedOrigin=${expectedOrigin};const apply=theme=>{if(theme!=="light"&&theme!=="dark")return;const root=document.documentElement;const modeClass=theme+"-mode";const usesPlainMode=root.classList.contains("light")||root.classList.contains("dark");root.dataset.colorMode=theme;root.dataset.theme=theme;root.setAttribute("data-color-scheme",theme);root.setAttribute("data-bs-theme",theme);root.style.colorScheme=theme;root.classList.remove("light-mode","dark-mode");root.classList.add(modeClass);if(usesPlainMode){root.classList.toggle("light",theme==="light");root.classList.toggle("dark",theme==="dark")}try{localStorage.setItem("theme",modeClass)}catch{}};apply(${initialTheme});addEventListener("message",event=>{if(event.source!==window.parent||event.origin!==expectedOrigin||event.data?.type!=="copilot-preview-theme")return;apply(event.data.theme)});})();</script>`;
|
||||
}
|
||||
|
||||
function insertionOffset(buffer, fallback = false) {
|
||||
const source = buffer.toString("utf8");
|
||||
const element = /<html(?:\s[^>]*)?>/i.exec(source) ?? /<head(?:\s[^>]*)?>/i.exec(source);
|
||||
if (element) {
|
||||
return Buffer.byteLength(
|
||||
source.slice(0, element.index + element[0].length),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
if (!fallback) return null;
|
||||
const doctype = /<!doctype[^>]*>/i.exec(source);
|
||||
return doctype
|
||||
? Buffer.byteLength(source.slice(0, doctype.index + doctype[0].length), "utf8")
|
||||
: 0;
|
||||
}
|
||||
|
||||
function createHtmlInjectionTransform(markup) {
|
||||
const injection = Buffer.from(markup, "utf8");
|
||||
let pending = Buffer.alloc(0);
|
||||
let injected = false;
|
||||
|
||||
return new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
if (injected) {
|
||||
callback(null, chunk);
|
||||
return;
|
||||
}
|
||||
pending = Buffer.concat([pending, Buffer.from(chunk)]);
|
||||
const offset = insertionOffset(pending);
|
||||
if (offset !== null || pending.length >= HTML_INJECTION_SEARCH_BYTES) {
|
||||
const resolvedOffset = offset ?? insertionOffset(pending, true);
|
||||
this.push(pending.subarray(0, resolvedOffset));
|
||||
this.push(injection);
|
||||
this.push(pending.subarray(resolvedOffset));
|
||||
pending = Buffer.alloc(0);
|
||||
injected = true;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
flush(callback) {
|
||||
if (!injected) {
|
||||
const offset = insertionOffset(pending, true);
|
||||
this.push(pending.subarray(0, offset));
|
||||
this.push(injection);
|
||||
this.push(pending.subarray(offset));
|
||||
}
|
||||
callback();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function decodeRequestPath(pathname) {
|
||||
try {
|
||||
return pathname
|
||||
.split("/")
|
||||
.map((part) => decodeURIComponent(part))
|
||||
.join("/")
|
||||
.replace(/^\/+/, "");
|
||||
} catch (error) {
|
||||
throw Object.assign(
|
||||
new Error("Preview URL contains invalid encoding.", { cause: error }),
|
||||
{ statusCode: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function safeRelativePath(path) {
|
||||
if (!path) return "";
|
||||
const normalized = posix.normalize(path.replaceAll("\\", "/"));
|
||||
if (
|
||||
normalized === ".." ||
|
||||
normalized.startsWith("../") ||
|
||||
normalized.startsWith("/") ||
|
||||
/^[A-Za-z]:\//.test(normalized)
|
||||
) {
|
||||
throw Object.assign(new Error("Preview path escapes the artifact root."), { statusCode: 400 });
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function servePreviewRequest(
|
||||
artifactId,
|
||||
root,
|
||||
entryName,
|
||||
theme,
|
||||
parentOrigin,
|
||||
serverEntry,
|
||||
req,
|
||||
res,
|
||||
) {
|
||||
if (!isCanonicalHost(req, serverEntry.host)) {
|
||||
res.writeHead(403, {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Security-Policy": PREVIEW_CSP,
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
res.end("Preview host is not allowed.");
|
||||
return;
|
||||
}
|
||||
if (!["GET", "HEAD"].includes(req.method)) {
|
||||
res.writeHead(405, { Allow: "GET, HEAD" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(req.url, "http://127.0.0.1");
|
||||
let relative = safeRelativePath(decodeRequestPath(url.pathname));
|
||||
if (!relative || relative.endsWith("/")) {
|
||||
relative = `${relative}${posix.basename(entryName)}`;
|
||||
}
|
||||
const requestedEntry = safeRelativePath(root ? posix.join(root, relative) : relative);
|
||||
const context = await getCachedEntry(artifactId, requestedEntry);
|
||||
if (!context.entry.supported) {
|
||||
res.writeHead(415, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("This file uses an unsupported ZIP encoding.");
|
||||
return;
|
||||
}
|
||||
const contentType = mimeForPath(context.entry.name);
|
||||
const themeBridge = contentType.startsWith("text/html")
|
||||
? themeBridgeMarkup(theme, parentOrigin)
|
||||
: "";
|
||||
res.writeHead(200, {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Length": String(
|
||||
context.entry.uncompressedSize + Buffer.byteLength(themeBridge),
|
||||
),
|
||||
"Content-Security-Policy": PREVIEW_CSP,
|
||||
"Content-Type": contentType,
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (themeBridge && context.entry.uncompressedSize === 0) {
|
||||
res.end(themeBridge);
|
||||
return;
|
||||
}
|
||||
await streamZipEntry(
|
||||
context.archivePath,
|
||||
context.entry,
|
||||
res,
|
||||
themeBridge ? createHtmlInjectionTransform(themeBridge) : null,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!res.headersSent) {
|
||||
const status = error?.statusCode === 400 ? 400 : 404;
|
||||
res.writeHead(status, {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Security-Policy": PREVIEW_CSP,
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
}
|
||||
res.end(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function startStaticPreview(artifactId, entryPath, options) {
|
||||
const context = await getCachedEntry(artifactId, entryPath);
|
||||
if (!context.entry.supported) {
|
||||
throw new Error("This file uses an unsupported ZIP encoding.");
|
||||
}
|
||||
if (!mimeForPath(context.entry.name).startsWith("text/html")) {
|
||||
throw new Error("Only HTML files can start a static preview.");
|
||||
}
|
||||
if (
|
||||
posix.dirname(context.entry.name) !== "." ||
|
||||
posix.basename(context.entry.name).toLowerCase() !== "index.html"
|
||||
) {
|
||||
throw new Error("Static previews require the root index.html file.");
|
||||
}
|
||||
const rootValue = posix.dirname(context.entry.name);
|
||||
const root = rootValue === "." ? "" : rootValue;
|
||||
const theme = normalizeTheme(options?.theme);
|
||||
const parentOrigin = normalizeParentOrigin(options?.parentOrigin);
|
||||
const key = JSON.stringify([
|
||||
String(artifactId),
|
||||
root,
|
||||
posix.basename(context.entry.name),
|
||||
theme,
|
||||
parentOrigin,
|
||||
]);
|
||||
const existing = previewServers.get(key);
|
||||
if (existing) return existing.url;
|
||||
|
||||
const serverEntry = { host: "" };
|
||||
const server = createServer((req, res) => {
|
||||
void servePreviewRequest(
|
||||
String(artifactId),
|
||||
root,
|
||||
context.entry.name,
|
||||
theme,
|
||||
parentOrigin,
|
||||
serverEntry,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
throw new Error("Static preview server did not receive a TCP port.");
|
||||
}
|
||||
serverEntry.host = `127.0.0.1:${address.port}`;
|
||||
const url = `http://127.0.0.1:${address.port}/`;
|
||||
previewServers.set(key, { artifactId: String(artifactId), server, url, canvasOrigin: parentOrigin });
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function stopStaticPreviewsForArtifact(artifactId) {
|
||||
const id = String(artifactId);
|
||||
const matching = [...previewServers.entries()].filter(
|
||||
([, value]) => value.artifactId === id,
|
||||
);
|
||||
await Promise.all(
|
||||
matching.map(async ([key, value]) => {
|
||||
previewServers.delete(key);
|
||||
await new Promise((resolve) => value.server.close(resolve));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function stopStaticPreviewsForOrigin(canvasOrigin) {
|
||||
const matching = [...previewServers.entries()].filter(
|
||||
([, value]) => value.canvasOrigin === canvasOrigin,
|
||||
);
|
||||
await Promise.all(
|
||||
matching.map(async ([key, value]) => {
|
||||
previewServers.delete(key);
|
||||
await new Promise((resolve) => value.server.close(resolve));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function stopAllStaticPreviews() {
|
||||
const active = [...previewServers.values()];
|
||||
previewServers.clear();
|
||||
await Promise.all(
|
||||
active.map((value) => new Promise((resolve) => value.server.close(resolve))),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
const CAPABILITY_TOKEN_MARKER = "__PR_ARTIFACT_EXPLORER_CAPABILITY_TOKEN__";
|
||||
|
||||
function escapeHtmlAttribute(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
export const HTML = `<!doctype html>
|
||||
<html lang="en" data-color-mode="auto" data-light-theme="light" data-dark-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="pr-artifact-explorer-token" content="${CAPABILITY_TOKEN_MARKER}" />
|
||||
<title>PR Artifact Explorer</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="stylesheet" href="/assets/primer-color-modes.css" />
|
||||
<link rel="stylesheet" href="/assets/primer-core.css" />
|
||||
<link rel="stylesheet" href="/assets/primer-product.css" />
|
||||
<link rel="stylesheet" href="/assets/asciinema-player.css" />
|
||||
<link rel="stylesheet" href="/assets/app.css" />
|
||||
<script defer src="/assets/asciinema-player.min.js"></script>
|
||||
<script defer src="/assets/trx-preview.js"></script>
|
||||
<script defer src="/assets/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="Header app-header">
|
||||
<a class="Header-item Header-link app-brand" href="#/" aria-label="Artifact Explorer home">
|
||||
<span class="octicon icon-mark-github app-brand-mark" aria-hidden="true"></span>
|
||||
<span>Artifact Explorer</span>
|
||||
</a>
|
||||
<form id="repository-form" class="Header-item Header-item--full repository-form" role="search" novalidate>
|
||||
<div id="repository-combobox" class="repository-combobox">
|
||||
<div class="repository-input-shell">
|
||||
<label class="sr-only" for="repository-input">Repository or pull request</label>
|
||||
<span class="octicon icon-search repository-search-icon" aria-hidden="true"></span>
|
||||
<input
|
||||
id="repository-input"
|
||||
class="Header-input repository-input"
|
||||
autocomplete="off"
|
||||
placeholder="Find a repository"
|
||||
spellcheck="false"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="repository-results"
|
||||
aria-expanded="false"
|
||||
/>
|
||||
<button
|
||||
id="repository-menu-button"
|
||||
class="repository-menu-button"
|
||||
type="button"
|
||||
aria-label="Open repository quick switch"
|
||||
aria-controls="repository-panel"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="octicon icon-chevron-down" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="repository-panel" class="repository-panel" hidden>
|
||||
<div class="repository-panel-header">
|
||||
<strong>Repositories</strong>
|
||||
<span>Pin one and favorite up to three</span>
|
||||
</div>
|
||||
<div id="repository-results" class="repository-results" role="listbox"></div>
|
||||
<div class="repository-panel-footer">Type <code>owner/</code> to browse that account.</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="Header-item header-cache-item">
|
||||
<a id="cache-link" class="Header-link cache-link" href="#/cache">
|
||||
<span class="octicon icon-package" aria-hidden="true"></span>
|
||||
<span id="cache-label">Cache</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="Header-item account-control">
|
||||
<a id="account-button" class="account-chip" href="#/accounts" aria-label="GitHub account" title="GitHub account">
|
||||
<span id="account-fallback" class="account-avatar-fallback">
|
||||
<span class="octicon icon-person" aria-hidden="true"></span>
|
||||
</span>
|
||||
<img id="account-avatar" class="account-avatar" alt="" hidden />
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<nav id="breadcrumbs" class="breadcrumb-bar" aria-label="Breadcrumb"></nav>
|
||||
<main id="view" class="container-xl app-main" aria-live="polite"></main>
|
||||
<div id="toast-region" class="toast-region" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
export function renderHtml(capabilityToken) {
|
||||
return HTML.replace(
|
||||
CAPABILITY_TOKEN_MARKER,
|
||||
escapeHtmlAttribute(capabilityToken),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
analyzeArtifact,
|
||||
hasRootIndexHtml,
|
||||
} from "./detector.mjs";
|
||||
import { CacheMaintenanceCoordinator } from "./cache-coordinator.mjs";
|
||||
import {
|
||||
enrichPullRequests,
|
||||
filterPullRequests,
|
||||
listPullRequestArtifacts,
|
||||
} from "./github.mjs";
|
||||
import { contentDeliveryMode, startInstance, stopInstance } from "./server.mjs";
|
||||
import {
|
||||
CAPABILITY_TOKEN_HEADER,
|
||||
hasCapabilityToken,
|
||||
isCanonicalHost,
|
||||
isCrossSiteRequest,
|
||||
requiresCapabilityToken,
|
||||
} from "./security.mjs";
|
||||
import {
|
||||
DEFAULT_PREFS,
|
||||
normalizeRepository,
|
||||
normalizePrefs,
|
||||
rememberRepository,
|
||||
} from "./state.mjs";
|
||||
|
||||
const here = new URL(".", import.meta.url);
|
||||
|
||||
function jsonResponse(payload, status = 200) {
|
||||
return {
|
||||
headers: { get: () => "application/json" },
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: async () => JSON.stringify(payload),
|
||||
};
|
||||
}
|
||||
|
||||
function rawStatus(url, headers) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = httpRequest(url, { headers }, (response) => {
|
||||
response.resume();
|
||||
response.on("end", () => resolve(response.statusCode));
|
||||
});
|
||||
request.on("error", reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((complete) => {
|
||||
resolve = complete;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
test("malformed recent repository values normalize to an empty list", () => {
|
||||
for (const recent of [{ unexpected: true }, 42]) {
|
||||
const preferences = normalizePrefs({
|
||||
repository: "github/awesome-copilot",
|
||||
repositories: { recent },
|
||||
});
|
||||
assert.deepEqual(preferences.repositories.recent, [
|
||||
"github/awesome-copilot",
|
||||
]);
|
||||
}
|
||||
|
||||
const fallback = normalizePrefs({
|
||||
repository: "not a repository",
|
||||
repositories: { recent: 42 },
|
||||
});
|
||||
assert.equal(fallback.repository, DEFAULT_PREFS.repository);
|
||||
assert.deepEqual(fallback.repositories.recent, [DEFAULT_PREFS.repository]);
|
||||
|
||||
const preferences = {
|
||||
repositories: { recent: { unexpected: true } },
|
||||
};
|
||||
rememberRepository(preferences, "github/awesome-copilot");
|
||||
assert.deepEqual(preferences.repositories.recent, [
|
||||
"github/awesome-copilot",
|
||||
]);
|
||||
});
|
||||
|
||||
test("artifact filtering pages repository artifacts once and caches head SHAs", async (t) => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const artifactRequests = [];
|
||||
t.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
const requestUrl = new URL(url);
|
||||
if (requestUrl.pathname === "/graphql") {
|
||||
const body = JSON.parse(options.body);
|
||||
assert.equal(body.variables.owner, "example");
|
||||
assert.equal(body.variables.name, "artifact-cache-test");
|
||||
return jsonResponse({
|
||||
data: {
|
||||
repository: {
|
||||
pull0: {
|
||||
headRefOid: "head-with-artifact",
|
||||
isDraft: false,
|
||||
reviewDecision: null,
|
||||
totalCommentsCount: 0,
|
||||
commits: { nodes: [] },
|
||||
},
|
||||
pull1: {
|
||||
headRefOid: "head-without-artifact",
|
||||
isDraft: false,
|
||||
reviewDecision: null,
|
||||
totalCommentsCount: 0,
|
||||
commits: { nodes: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
requestUrl.pathname,
|
||||
"/repos/example/artifact-cache-test/actions/artifacts",
|
||||
);
|
||||
artifactRequests.push(requestUrl.search);
|
||||
const page = Number(requestUrl.searchParams.get("page"));
|
||||
if (page === 1) {
|
||||
return jsonResponse({
|
||||
total_count: 101,
|
||||
artifacts: Array.from({ length: 100 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
workflow_run: { head_sha: "unrelated-head" },
|
||||
})),
|
||||
});
|
||||
}
|
||||
assert.equal(page, 2);
|
||||
return jsonResponse({
|
||||
total_count: 101,
|
||||
artifacts: [
|
||||
{
|
||||
id: 101,
|
||||
workflow_run: { head_sha: "head-with-artifact" },
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const pulls = [{ number: 1 }, { number: 2 }];
|
||||
const filters = { artifacts: "with" };
|
||||
const first = await enrichPullRequests(
|
||||
"token",
|
||||
"example/artifact-cache-test",
|
||||
pulls,
|
||||
filters,
|
||||
);
|
||||
assert.deepEqual(
|
||||
first.map((pull) => pull.hasArtifacts),
|
||||
[true, false],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterPullRequests(first, filters).map((pull) => pull.number),
|
||||
[1],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterPullRequests(first, { artifacts: "without" }).map(
|
||||
(pull) => pull.number,
|
||||
),
|
||||
[2],
|
||||
);
|
||||
|
||||
await enrichPullRequests(
|
||||
"token",
|
||||
"example/artifact-cache-test",
|
||||
pulls,
|
||||
filters,
|
||||
);
|
||||
assert.equal(artifactRequests.length, 2);
|
||||
assert.match(artifactRequests[0], /per_page=100/);
|
||||
assert.match(artifactRequests[0], /page=1/);
|
||||
assert.match(artifactRequests[1], /page=2/);
|
||||
});
|
||||
|
||||
test("pull artifact results report the intentional workflow-run cap", async (t) => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const artifactRequests = [];
|
||||
t.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
const requestUrl = new URL(url);
|
||||
if (requestUrl.pathname === "/repos/example/run-cap/pulls/7") {
|
||||
return jsonResponse({
|
||||
number: 7,
|
||||
title: "Run cap",
|
||||
state: "open",
|
||||
head: { sha: "head-sha", ref: "feature" },
|
||||
base: { ref: "main" },
|
||||
user: { login: "octocat" },
|
||||
labels: [],
|
||||
});
|
||||
}
|
||||
if (requestUrl.pathname === "/repos/example/run-cap/actions/runs") {
|
||||
assert.equal(requestUrl.searchParams.get("per_page"), "30");
|
||||
return jsonResponse({
|
||||
total_count: 35,
|
||||
workflow_runs: Array.from({ length: 30 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
name: "CI",
|
||||
run_number: index + 1,
|
||||
run_attempt: 1,
|
||||
event: "pull_request",
|
||||
status: "completed",
|
||||
conclusion: "success",
|
||||
created_at: new Date(Date.UTC(2026, 0, 1, 0, 0, 35 - index)).toISOString(),
|
||||
html_url: `https://github.com/example/run-cap/actions/runs/${index + 1}`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
const artifactMatch = requestUrl.pathname.match(
|
||||
/^\/repos\/example\/run-cap\/actions\/runs\/(\d+)\/artifacts$/,
|
||||
);
|
||||
assert.ok(artifactMatch);
|
||||
artifactRequests.push(Number(artifactMatch[1]));
|
||||
return jsonResponse({
|
||||
total_count: 1,
|
||||
artifacts: [
|
||||
{
|
||||
id: Number(artifactMatch[1]) * 10,
|
||||
name: `artifact-${artifactMatch[1]}`,
|
||||
size_in_bytes: 100,
|
||||
expired: false,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
expires_at: "2026-04-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const result = await listPullRequestArtifacts(
|
||||
"token",
|
||||
"example/run-cap",
|
||||
7,
|
||||
);
|
||||
assert.equal(artifactRequests.length, 30);
|
||||
assert.equal(result.runs.length, 30);
|
||||
assert.equal(result.runCount, 35);
|
||||
assert.equal(result.runsTruncated, true);
|
||||
assert.equal(result.artifacts.length, 30);
|
||||
assert.equal(result.artifactCount, 30);
|
||||
assert.equal(result.artifactsTruncated, true);
|
||||
});
|
||||
|
||||
test("only the archive root index uses static-site preview routing", async () => {
|
||||
const source = await readFile(new URL("assets/app.js", here), "utf8");
|
||||
const helpers = source.slice(
|
||||
source.indexOf("function artifactEntryKind"),
|
||||
source.indexOf("function directoryPathsForFile"),
|
||||
);
|
||||
const shouldUseStaticSitePreview = Function(
|
||||
`${helpers}; return shouldUseStaticSitePreview;`,
|
||||
)();
|
||||
const root = { path: "index.html", kind: "html", supported: true };
|
||||
const nested = {
|
||||
path: "docs/index.html",
|
||||
kind: "html",
|
||||
supported: true,
|
||||
};
|
||||
const unsupported = {
|
||||
path: "index.html",
|
||||
kind: "html",
|
||||
supported: false,
|
||||
};
|
||||
const entries = [root, nested];
|
||||
|
||||
assert.equal(shouldUseStaticSitePreview(root, entries), true);
|
||||
assert.equal(shouldUseStaticSitePreview(nested, entries), false);
|
||||
assert.equal(shouldUseStaticSitePreview(nested, [nested]), false);
|
||||
assert.equal(
|
||||
shouldUseStaticSitePreview(unsupported, [unsupported]),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("completed TRX summaries derive their displayed outcome from counters", async () => {
|
||||
const source = await readFile(
|
||||
new URL("assets/trx-preview.js", here),
|
||||
"utf8",
|
||||
);
|
||||
const helpers = source.slice(
|
||||
source.indexOf("const KNOWN_TRX_OUTCOMES"),
|
||||
source.indexOf("function parseDuration"),
|
||||
);
|
||||
const summarizedRunOutcome = Function(
|
||||
`${helpers}; return summarizedRunOutcome;`,
|
||||
)();
|
||||
|
||||
assert.equal(
|
||||
summarizedRunOutcome({
|
||||
outcome: "Completed",
|
||||
summaryCounts: { failed: 1, passed: 4, skipped: 0 },
|
||||
}),
|
||||
"Failed",
|
||||
);
|
||||
assert.equal(
|
||||
summarizedRunOutcome({
|
||||
outcome: "Completed",
|
||||
summaryCounts: { failed: 0, passed: 5, skipped: 0 },
|
||||
}),
|
||||
"Passed",
|
||||
);
|
||||
assert.equal(
|
||||
summarizedRunOutcome({
|
||||
outcome: "Completed",
|
||||
summaryCounts: { failed: 0, passed: 0, skipped: 2 },
|
||||
}),
|
||||
"Skipped",
|
||||
);
|
||||
assert.equal(
|
||||
summarizedRunOutcome({
|
||||
outcome: "Completed",
|
||||
summaryCounts: { failed: 0, passed: 0, skipped: 0 },
|
||||
}),
|
||||
"Completed",
|
||||
);
|
||||
assert.equal(
|
||||
summarizedRunOutcome({
|
||||
outcome: "Failed",
|
||||
summaryCounts: { failed: 0, passed: 5, skipped: 0 },
|
||||
}),
|
||||
"Failed",
|
||||
);
|
||||
});
|
||||
|
||||
test("repository normalization rejects owner and name dot segments", () => {
|
||||
for (const repository of ["../user", "./repo", "owner/..", "owner/."]) {
|
||||
assert.throws(
|
||||
() => normalizeRepository(repository),
|
||||
/cannot be dot segments/,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
normalizeRepository("github/awesome-copilot"),
|
||||
"github/awesome-copilot",
|
||||
);
|
||||
});
|
||||
|
||||
test("unsupported entries remain listed but cannot become the primary preview", async () => {
|
||||
const analysis = await analyzeArtifact(
|
||||
{
|
||||
entries: [
|
||||
{
|
||||
name: "index.html",
|
||||
directory: false,
|
||||
supported: false,
|
||||
compressedSize: 10,
|
||||
uncompressedSize: 20,
|
||||
},
|
||||
{
|
||||
name: "session.cast",
|
||||
directory: false,
|
||||
supported: false,
|
||||
compressedSize: 10,
|
||||
uncompressedSize: 20,
|
||||
},
|
||||
{
|
||||
name: "results.trx",
|
||||
directory: false,
|
||||
supported: true,
|
||||
compressedSize: 10,
|
||||
uncompressedSize: 20,
|
||||
},
|
||||
],
|
||||
totalUncompressedBytes: 60,
|
||||
},
|
||||
async () => {
|
||||
throw new Error("Unsupported entries must not be probed.");
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(analysis.entries.length, 3);
|
||||
assert.deepEqual(analysis.primary, {
|
||||
kind: "trx",
|
||||
path: "results.trx",
|
||||
label: "Test results",
|
||||
});
|
||||
assert.equal(hasRootIndexHtml(analysis.entries), false);
|
||||
});
|
||||
|
||||
test("unsupported entry downloads fall back to the original artifact archive", () => {
|
||||
assert.equal(contentDeliveryMode({ supported: false }, false), "unsupported");
|
||||
assert.equal(contentDeliveryMode({ supported: false }, true), "archive");
|
||||
assert.equal(contentDeliveryMode({ supported: true }, true), "entry");
|
||||
});
|
||||
|
||||
test("cache maintenance blocks inspections during artifact deletion", async () => {
|
||||
const coordinator = new CacheMaintenanceCoordinator();
|
||||
const deletionEntered = deferred();
|
||||
const releaseDeletion = deferred();
|
||||
const deletion = coordinator.deleteArtifact("42", async () => {
|
||||
deletionEntered.resolve();
|
||||
await releaseDeletion.promise;
|
||||
return { deleted: "42" };
|
||||
});
|
||||
await deletionEntered.promise;
|
||||
|
||||
let inspectionStarted = false;
|
||||
const inspection = (async () => {
|
||||
while (true) {
|
||||
const barrier = coordinator.inspectionBarrier("42");
|
||||
if (!barrier) break;
|
||||
await barrier;
|
||||
}
|
||||
inspectionStarted = true;
|
||||
})();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(inspectionStarted, false);
|
||||
assert.equal(coordinator.inspectionBarrier("99"), null);
|
||||
|
||||
releaseDeletion.resolve();
|
||||
assert.deepEqual(await deletion, { deleted: "42" });
|
||||
await inspection;
|
||||
assert.equal(inspectionStarted, true);
|
||||
});
|
||||
|
||||
test("cache clearing waits for deletions and blocks every inspection", async () => {
|
||||
const coordinator = new CacheMaintenanceCoordinator();
|
||||
const deletionEntered = deferred();
|
||||
const releaseDeletion = deferred();
|
||||
const deletion = coordinator.deleteArtifact("42", async () => {
|
||||
deletionEntered.resolve();
|
||||
await releaseDeletion.promise;
|
||||
});
|
||||
await deletionEntered.promise;
|
||||
|
||||
const clearEntered = deferred();
|
||||
const releaseClear = deferred();
|
||||
const clear = coordinator.clearCache(async () => {
|
||||
clearEntered.resolve();
|
||||
await releaseClear.promise;
|
||||
return { cleared: true };
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(coordinator.inspectionBarrier("99") !== null, true);
|
||||
|
||||
let inspectionStarted = false;
|
||||
const inspection = (async () => {
|
||||
while (true) {
|
||||
const barrier = coordinator.inspectionBarrier("99");
|
||||
if (!barrier) break;
|
||||
await barrier;
|
||||
}
|
||||
inspectionStarted = true;
|
||||
})();
|
||||
releaseDeletion.resolve();
|
||||
await deletion;
|
||||
await clearEntered.promise;
|
||||
assert.equal(inspectionStarted, false);
|
||||
|
||||
releaseClear.resolve();
|
||||
assert.deepEqual(await clear, { cleared: true });
|
||||
await inspection;
|
||||
assert.equal(inspectionStarted, true);
|
||||
});
|
||||
|
||||
test("artifact truncation messaging names omitted workflow runs", async () => {
|
||||
const source = await readFile(new URL("assets/app.js", here), "utf8");
|
||||
const helperSource = source.slice(
|
||||
source.indexOf("function artifactResultLimitMessage"),
|
||||
source.indexOf("function renderPullPayload"),
|
||||
);
|
||||
const artifactResultLimitMessage = Function(
|
||||
`${helperSource}; return artifactResultLimitMessage;`,
|
||||
)();
|
||||
|
||||
assert.equal(
|
||||
artifactResultLimitMessage({
|
||||
runs: Array.from({ length: 30 }),
|
||||
runCount: 35,
|
||||
runsTruncated: true,
|
||||
artifacts: Array.from({ length: 100 }),
|
||||
artifactCount: 120,
|
||||
}),
|
||||
"Showing artifacts from the newest 30 of 35 workflow runs. Showing the newest 100 of 120 artifacts from those runs.",
|
||||
);
|
||||
});
|
||||
|
||||
test("capability tokens protect loopback APIs, content, and events", () => {
|
||||
const token = "secret-token";
|
||||
const req = (headers = {}) => ({ headers });
|
||||
const apiUrl = new URL("http://127.0.0.1:1234/api/bootstrap");
|
||||
const contentUrl = new URL(
|
||||
`http://127.0.0.1:1234/content/1/file.txt?token=${token}`,
|
||||
);
|
||||
const eventsUrl = new URL(
|
||||
`http://127.0.0.1:1234/events?token=${token}`,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isCanonicalHost(req({ host: "127.0.0.1:1234" }), "127.0.0.1:1234"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isCanonicalHost(req({ host: "attacker.example:1234" }), "127.0.0.1:1234"),
|
||||
false,
|
||||
);
|
||||
assert.equal(requiresCapabilityToken("/api/bootstrap"), true);
|
||||
assert.equal(requiresCapabilityToken("/content/1/file.txt"), true);
|
||||
assert.equal(requiresCapabilityToken("/events"), true);
|
||||
assert.equal(requiresCapabilityToken("/assets/app.js"), false);
|
||||
assert.equal(
|
||||
hasCapabilityToken(req({ [CAPABILITY_TOKEN_HEADER]: token }), apiUrl, token),
|
||||
true,
|
||||
);
|
||||
assert.equal(hasCapabilityToken(req(), contentUrl, token), true);
|
||||
assert.equal(hasCapabilityToken(req(), eventsUrl, token), true);
|
||||
assert.equal(
|
||||
hasCapabilityToken(
|
||||
req(),
|
||||
new URL(`${apiUrl}?token=${token}`),
|
||||
token,
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isCrossSiteRequest(
|
||||
req({ origin: "https://attacker.example" }),
|
||||
"http://127.0.0.1:1234",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("canvas server rejects spoofed hosts and unauthenticated protected routes", async (t) => {
|
||||
const instanceId = `review-security-${Date.now()}`;
|
||||
const entry = await startInstance(instanceId, null, () => {});
|
||||
t.after(() => stopInstance(instanceId));
|
||||
|
||||
const root = await fetch(entry.url);
|
||||
assert.equal(root.status, 200);
|
||||
assert.match(
|
||||
await root.text(),
|
||||
new RegExp(`name="pr-artifact-explorer-token" content="${entry.token}"`),
|
||||
);
|
||||
assert.equal((await fetch(`${entry.origin}/`)).status, 403);
|
||||
|
||||
for (const path of ["api/bootstrap", "content/1/file.txt", "events"]) {
|
||||
const response = await fetch(`${entry.origin}/${path}`);
|
||||
assert.equal(response.status, 403);
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
await rawStatus(entry.url, { Host: "attacker.example" }),
|
||||
403,
|
||||
);
|
||||
const foreignOriginResponse = await fetch(`${entry.origin}/api/bootstrap`, {
|
||||
headers: {
|
||||
[CAPABILITY_TOKEN_HEADER]: entry.token,
|
||||
Origin: "https://attacker.example",
|
||||
},
|
||||
});
|
||||
assert.equal(foreignOriginResponse.status, 403);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
export const CAPABILITY_TOKEN_HEADER = "x-pr-artifact-explorer-token";
|
||||
|
||||
export function isCanonicalHost(req, canonicalHost) {
|
||||
return (
|
||||
String(req.headers.host ?? "").toLowerCase() ===
|
||||
String(canonicalHost ?? "").toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
export function isCrossSiteRequest(req, canonicalOrigin) {
|
||||
const origin = req.headers.origin;
|
||||
if (origin) {
|
||||
if (origin === canonicalOrigin) return false;
|
||||
if (origin === "null") return true;
|
||||
if (/^https?:\/\//i.test(origin)) return true;
|
||||
return false;
|
||||
}
|
||||
const site = req.headers["sec-fetch-site"];
|
||||
return site === "cross-site" || site === "same-site";
|
||||
}
|
||||
|
||||
export function requiresCapabilityToken(pathname) {
|
||||
return (
|
||||
pathname.startsWith("/api/") ||
|
||||
pathname.startsWith("/content/") ||
|
||||
pathname === "/events"
|
||||
);
|
||||
}
|
||||
|
||||
export function hasCapabilityToken(req, url, expectedToken) {
|
||||
if (!expectedToken) return false;
|
||||
const header = req.headers[CAPABILITY_TOKEN_HEADER];
|
||||
const headerToken = Array.isArray(header) ? header[0] : header;
|
||||
if (headerToken === expectedToken) return true;
|
||||
const allowsQueryToken =
|
||||
url.pathname.startsWith("/content/") || url.pathname === "/events";
|
||||
return allowsQueryToken && url.searchParams.get("token") === expectedToken;
|
||||
}
|
||||
@@ -0,0 +1,962 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { join, posix } from "node:path";
|
||||
import { createServer } from "node:http";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { resolveAccounts, invalidateAccounts } from "./accounts.mjs";
|
||||
import {
|
||||
clearArtifactCache,
|
||||
deleteCachedArtifact,
|
||||
getCacheSummary,
|
||||
getCachedArtifact,
|
||||
getCachedEntry,
|
||||
inspectArtifact,
|
||||
readCachedEntry,
|
||||
} from "./cache.mjs";
|
||||
import {
|
||||
ASSET_ROOT,
|
||||
MAX_INLINE_PREVIEW_BYTES,
|
||||
MAX_STREAMED_ENTRY_BYTES,
|
||||
MAX_TRX_PREVIEW_BYTES,
|
||||
} from "./constants.mjs";
|
||||
import {
|
||||
hasRootIndexHtml,
|
||||
isInlineTextKind,
|
||||
kindForPath,
|
||||
mimeForPath,
|
||||
} from "./detector.mjs";
|
||||
import {
|
||||
enrichPullRequests,
|
||||
filterPullRequests,
|
||||
getRepository,
|
||||
GitHubApiError,
|
||||
listRepositoryContributors,
|
||||
listPullRequestArtifacts,
|
||||
searchPullRequestBase,
|
||||
searchPullRequests,
|
||||
suggestRepositories,
|
||||
} from "./github.mjs";
|
||||
import {
|
||||
startStaticPreview,
|
||||
stopAllStaticPreviews,
|
||||
stopStaticPreviewsForArtifact,
|
||||
stopStaticPreviewsForOrigin,
|
||||
} from "./preview.mjs";
|
||||
import { ExpiringPromiseCache } from "./memory-cache.mjs";
|
||||
import { renderHtml } from "./render.mjs";
|
||||
import {
|
||||
hasCapabilityToken,
|
||||
isCanonicalHost,
|
||||
isCrossSiteRequest,
|
||||
requiresCapabilityToken,
|
||||
} from "./security.mjs";
|
||||
import {
|
||||
loadPrefs,
|
||||
normalizeRepository,
|
||||
rememberRepository,
|
||||
savePrefs,
|
||||
setExplorerPreferences,
|
||||
setFavoriteRepository,
|
||||
setPinnedRepository,
|
||||
setPlayerPreferences,
|
||||
setPullFilterPreferences,
|
||||
} from "./state.mjs";
|
||||
import { streamZipEntry } from "./zip.mjs";
|
||||
|
||||
const servers = new Map();
|
||||
const MAX_BODY_BYTES = 1024 * 1024;
|
||||
const PULL_REQUEST_CACHE_TTL_MS = 2 * 60 * 1_000;
|
||||
const PROGRESSIVE_PULL_BATCH_SIZE = 25;
|
||||
const pullRequestCache = new ExpiringPromiseCache({
|
||||
maxEntries: 100,
|
||||
ttlMs: PULL_REQUEST_CACHE_TTL_MS,
|
||||
});
|
||||
const progressivePullRequestCache = new ExpiringPromiseCache({
|
||||
maxEntries: 100,
|
||||
ttlMs: PULL_REQUEST_CACHE_TTL_MS,
|
||||
});
|
||||
|
||||
const MAIN_CSP = [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"connect-src 'self'",
|
||||
"font-src 'self' data:",
|
||||
"form-action 'self'",
|
||||
"frame-src http://127.0.0.1:*",
|
||||
"img-src 'self' data: https://avatars.githubusercontent.com https://github.com",
|
||||
"object-src 'none'",
|
||||
"script-src 'self' 'wasm-unsafe-eval'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"worker-src 'self' blob:",
|
||||
].join("; ");
|
||||
|
||||
class HttpError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function createCapabilityToken() {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
function sendJson(res, status, value) {
|
||||
const body = JSON.stringify(value);
|
||||
res.writeHead(status, {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function sendText(res, status, value, type = "text/plain; charset=utf-8", headers = {}) {
|
||||
const body = String(value);
|
||||
res.writeHead(status, {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
"Content-Type": type,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
...headers,
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function pullRequestCacheKey(account, kind, values) {
|
||||
return [
|
||||
account?.id ?? account?.login ?? "unknown",
|
||||
kind,
|
||||
...values.map((value) => String(value ?? "").toLocaleLowerCase()),
|
||||
].join("|");
|
||||
}
|
||||
|
||||
function progressivePullPayload(session, pulls, {
|
||||
complete = false,
|
||||
offset = 0,
|
||||
} = {}) {
|
||||
const filterActive =
|
||||
session.base.filters.artifacts !== "all" ||
|
||||
session.base.filters.ci !== "all";
|
||||
return {
|
||||
...session.base,
|
||||
filtered: complete && filterActive,
|
||||
pulls,
|
||||
progressive: {
|
||||
batchSize: PROGRESSIVE_PULL_BATCH_SIZE,
|
||||
complete,
|
||||
detailedCount: session.completedNumbers.size,
|
||||
loadedCount: Math.min(
|
||||
session.base.pulls.length,
|
||||
offset + pulls.length,
|
||||
),
|
||||
offset,
|
||||
sessionId: session.id,
|
||||
sourceCount: session.base.pulls.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function completeProgressivePullPayload(session) {
|
||||
const enriched = session.base.pulls
|
||||
.map((pull) => session.enrichedPulls.get(Number(pull.number)))
|
||||
.filter(Boolean);
|
||||
return progressivePullPayload(
|
||||
session,
|
||||
filterPullRequests(enriched, session.base.filters),
|
||||
{ complete: true },
|
||||
);
|
||||
}
|
||||
|
||||
async function loadProgressivePullDetails(
|
||||
session,
|
||||
cacheKey,
|
||||
token,
|
||||
repository,
|
||||
offset,
|
||||
) {
|
||||
const completed = session.detailResults.get(offset);
|
||||
if (completed) return completed;
|
||||
const existing = session.detailPromises.get(offset);
|
||||
if (existing) return existing;
|
||||
const source = session.base.pulls.slice(
|
||||
offset,
|
||||
offset + PROGRESSIVE_PULL_BATCH_SIZE,
|
||||
);
|
||||
const promise = (async () => {
|
||||
const enriched = await enrichPullRequests(
|
||||
token,
|
||||
repository,
|
||||
source,
|
||||
session.base.filters,
|
||||
);
|
||||
for (const pull of enriched) {
|
||||
session.enrichedPulls.set(Number(pull.number), pull);
|
||||
session.completedNumbers.add(Number(pull.number));
|
||||
}
|
||||
const sourceNumbers = source.map((pull) => Number(pull.number));
|
||||
const complete =
|
||||
session.completedNumbers.size >= session.base.pulls.length;
|
||||
const payload = complete ? completeProgressivePullPayload(session) : null;
|
||||
if (
|
||||
payload &&
|
||||
progressivePullRequestCache.peek(cacheKey) === session
|
||||
) {
|
||||
pullRequestCache.set(cacheKey, payload);
|
||||
}
|
||||
const result = {
|
||||
complete,
|
||||
completedCount: session.completedNumbers.size,
|
||||
payload,
|
||||
pulls: filterPullRequests(enriched, session.base.filters),
|
||||
sourceNumbers,
|
||||
};
|
||||
session.detailResults.set(offset, result);
|
||||
return result;
|
||||
})();
|
||||
session.detailPromises.set(offset, promise);
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
if (session.detailPromises.get(offset) === promise) {
|
||||
session.detailPromises.delete(offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendFile(res, filePath, type) {
|
||||
const info = await stat(filePath);
|
||||
if (!info.isFile()) throw new HttpError(404, "Asset not found.");
|
||||
res.writeHead(200, {
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"Content-Length": String(info.size),
|
||||
"Content-Type": type,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
await pipeline(createReadStream(filePath), res);
|
||||
}
|
||||
|
||||
async function readJsonBody(req) {
|
||||
const type = req.headers["content-type"] ?? "";
|
||||
if (!String(type).toLowerCase().startsWith("application/json")) {
|
||||
throw new HttpError(415, "Request body must be JSON.");
|
||||
}
|
||||
const chunks = [];
|
||||
let length = 0;
|
||||
for await (const chunk of req) {
|
||||
length += chunk.length;
|
||||
if (length > MAX_BODY_BYTES) throw new HttpError(413, "Request body is too large.");
|
||||
chunks.push(chunk);
|
||||
}
|
||||
if (!chunks.length) return {};
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
} catch (error) {
|
||||
throw new HttpError(400, "Request body is not valid JSON.", { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
function broadcast(entry, event, payload) {
|
||||
const encoded = JSON.stringify(payload);
|
||||
for (const response of entry.sseClients) {
|
||||
try {
|
||||
response.write(`event: ${event}\ndata: ${encoded}\n\n`);
|
||||
} catch {
|
||||
entry.sseClients.delete(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAuth(repository, force = false) {
|
||||
const prefs = await loadPrefs();
|
||||
const previousRepository = prefs.repository;
|
||||
const previousRecent = JSON.stringify(prefs.repositories.recent);
|
||||
const normalizedRepository = rememberRepository(prefs, repository);
|
||||
const auth = await resolveAccounts({
|
||||
preferredId: prefs.account,
|
||||
repository: normalizedRepository,
|
||||
force,
|
||||
});
|
||||
let changed =
|
||||
prefs.repository !== previousRepository ||
|
||||
JSON.stringify(prefs.repositories.recent) !== previousRecent;
|
||||
if (auth.active && prefs.account !== auth.active.id) {
|
||||
prefs.account = auth.active.id;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) await savePrefs(prefs);
|
||||
if (!auth.activeToken) {
|
||||
throw new HttpError(
|
||||
401,
|
||||
"No usable GitHub account was detected. Sign in through the Copilot app or GitHub CLI.",
|
||||
);
|
||||
}
|
||||
return { prefs, auth };
|
||||
}
|
||||
|
||||
function parseArtifactRoute(pathname) {
|
||||
return pathname.match(/^\/api\/artifacts\/(\d+)$/);
|
||||
}
|
||||
|
||||
function decodeEntryPath(encoded) {
|
||||
try {
|
||||
return encoded.split("/").map((part) => decodeURIComponent(part)).join("/");
|
||||
} catch (error) {
|
||||
throw new HttpError(400, "File path contains invalid URL encoding.", { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export function contentDeliveryMode(entry, download) {
|
||||
if (entry?.supported) return "entry";
|
||||
return download ? "archive" : "unsupported";
|
||||
}
|
||||
|
||||
function archiveDownloadName(context, artifactId) {
|
||||
const rawName = String(
|
||||
context.metadata?.artifact?.name || `artifact-${artifactId}`,
|
||||
).replaceAll("\\", "/");
|
||||
const baseName = posix.basename(rawName) || `artifact-${artifactId}`;
|
||||
return baseName.toLowerCase().endsWith(".zip")
|
||||
? baseName
|
||||
: `${baseName}.zip`;
|
||||
}
|
||||
|
||||
async function sendArchiveDownload(req, res, context, artifactId) {
|
||||
const info = await stat(context.archivePath);
|
||||
const filename = archiveDownloadName(context, artifactId);
|
||||
res.writeHead(200, {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition":
|
||||
`attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
||||
"Content-Length": String(info.size),
|
||||
"Content-Security-Policy": "default-src 'none'; sandbox",
|
||||
"Content-Type": "application/zip",
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
await pipeline(createReadStream(context.archivePath), res);
|
||||
}
|
||||
|
||||
async function handleContentRequest(req, res, pathname, searchParams) {
|
||||
const match = pathname.match(/^\/content\/(\d+)\/(.+)$/);
|
||||
if (!match) return false;
|
||||
if (!["GET", "HEAD"].includes(req.method)) {
|
||||
throw new HttpError(405, "Only GET and HEAD are supported.");
|
||||
}
|
||||
const artifactId = match[1];
|
||||
const entryPath = decodeEntryPath(match[2]);
|
||||
const context = await getCachedEntry(artifactId, entryPath);
|
||||
const download = searchParams.get("download") === "1";
|
||||
const deliveryMode = contentDeliveryMode(context.entry, download);
|
||||
if (deliveryMode === "unsupported") {
|
||||
throw new HttpError(415, "This ZIP entry cannot be served.");
|
||||
}
|
||||
if (deliveryMode === "archive") {
|
||||
await sendArchiveDownload(req, res, context, artifactId);
|
||||
return true;
|
||||
}
|
||||
if (context.entry.uncompressedSize > MAX_STREAMED_ENTRY_BYTES) {
|
||||
throw new HttpError(
|
||||
413,
|
||||
"This file exceeds the 512 MiB streaming safety limit.",
|
||||
);
|
||||
}
|
||||
|
||||
const headers = {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Length": String(context.entry.uncompressedSize),
|
||||
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
||||
"Content-Type": mimeForPath(context.entry.name),
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
};
|
||||
if (download) {
|
||||
headers["Content-Disposition"] =
|
||||
`attachment; filename*=UTF-8''${encodeURIComponent(posix.basename(context.entry.name))}`;
|
||||
}
|
||||
res.writeHead(200, headers);
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
} else {
|
||||
await streamZipEntry(context.archivePath, context.entry, res);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleApi(req, res, entry, url) {
|
||||
const path = url.pathname;
|
||||
if (req.method === "GET" && path === "/api/bootstrap") {
|
||||
const prefs = await loadPrefs();
|
||||
const repository =
|
||||
typeof entry.initialInput?.repository === "string"
|
||||
? normalizeRepository(entry.initialInput.repository)
|
||||
: prefs.repository;
|
||||
const auth = await resolveAccounts({
|
||||
preferredId: prefs.account,
|
||||
repository,
|
||||
});
|
||||
if (auth.active && prefs.account !== auth.active.id) {
|
||||
prefs.account = auth.active.id;
|
||||
await savePrefs(prefs);
|
||||
}
|
||||
return sendJson(res, 200, {
|
||||
prefs,
|
||||
account: auth.active,
|
||||
accounts: auth.accounts,
|
||||
cache: await getCacheSummary(),
|
||||
initialInput: entry.initialInput ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && path === "/api/accounts") {
|
||||
const prefs = await loadPrefs();
|
||||
const repository = normalizeRepository(
|
||||
url.searchParams.get("repo") || prefs.repository,
|
||||
);
|
||||
const { auth } = await resolveAuth(repository, true);
|
||||
pullRequestCache.clear();
|
||||
progressivePullRequestCache.clear();
|
||||
return sendJson(res, 200, {
|
||||
account: auth.active,
|
||||
accounts: auth.accounts,
|
||||
prefs: await loadPrefs(),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "POST" && path === "/api/account") {
|
||||
const body = await readJsonBody(req);
|
||||
if (typeof body.id !== "string" || !body.id) {
|
||||
throw new HttpError(400, "Account id is required.");
|
||||
}
|
||||
const repository = normalizeRepository(body.repository);
|
||||
const prefs = await loadPrefs();
|
||||
prefs.account = body.id;
|
||||
await savePrefs(prefs);
|
||||
invalidateAccounts();
|
||||
pullRequestCache.clear();
|
||||
progressivePullRequestCache.clear();
|
||||
const { auth } = await resolveAuth(repository, true);
|
||||
broadcast(entry, "refresh", { reason: "account" });
|
||||
return sendJson(res, 200, {
|
||||
account: auth.active,
|
||||
accounts: auth.accounts,
|
||||
prefs: await loadPrefs(),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && path === "/api/repositories/suggest") {
|
||||
const query = String(url.searchParams.get("q") ?? "").trim();
|
||||
const prefs = await loadPrefs();
|
||||
const auth = await resolveAccounts({
|
||||
preferredId: prefs.account,
|
||||
repository: prefs.repository,
|
||||
});
|
||||
if (!auth.activeToken) {
|
||||
throw new HttpError(401, "No usable GitHub account was found.");
|
||||
}
|
||||
return sendJson(res, 200, {
|
||||
repositories: await suggestRepositories(auth.activeToken, query),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "POST" && path === "/api/repositories/validate") {
|
||||
const body = await readJsonBody(req);
|
||||
const repository = normalizeRepository(body.repository);
|
||||
const prefs = await loadPrefs();
|
||||
const auth = await resolveAccounts({
|
||||
preferredId: prefs.account,
|
||||
repository,
|
||||
});
|
||||
if (!auth.activeToken) {
|
||||
throw new HttpError(401, "No usable GitHub account was found.");
|
||||
}
|
||||
return sendJson(res, 200, {
|
||||
repository: await getRepository(auth.activeToken, repository),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && path === "/api/repositories/authors") {
|
||||
const repository = normalizeRepository(url.searchParams.get("repo"));
|
||||
const { auth } = await resolveAuth(repository);
|
||||
return sendJson(res, 200, {
|
||||
authors: await listRepositoryContributors(auth.activeToken, repository),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "POST" && path === "/api/repositories/pin") {
|
||||
const body = await readJsonBody(req);
|
||||
const prefs = await loadPrefs();
|
||||
setPinnedRepository(prefs, body.repository);
|
||||
return sendJson(res, 200, await savePrefs(prefs));
|
||||
}
|
||||
|
||||
if (req.method === "POST" && path === "/api/repositories/favorite") {
|
||||
const body = await readJsonBody(req);
|
||||
if (typeof body.favorite !== "boolean") {
|
||||
throw new HttpError(400, "favorite must be a boolean.");
|
||||
}
|
||||
const prefs = await loadPrefs();
|
||||
try {
|
||||
setFavoriteRepository(prefs, body.repository, body.favorite);
|
||||
} catch (error) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
error instanceof Error ? error.message : "Repository could not be favorited.",
|
||||
);
|
||||
}
|
||||
return sendJson(res, 200, await savePrefs(prefs));
|
||||
}
|
||||
|
||||
if (req.method === "POST" && path === "/api/preferences") {
|
||||
const body = await readJsonBody(req);
|
||||
const prefs = await loadPrefs();
|
||||
if (typeof body.repository === "string") {
|
||||
rememberRepository(prefs, body.repository);
|
||||
}
|
||||
if (["open", "closed", "all"].includes(body.pullState)) {
|
||||
prefs.pullState = body.pullState;
|
||||
}
|
||||
if (
|
||||
typeof body.repository === "string" &&
|
||||
body.pullFilter &&
|
||||
typeof body.pullFilter === "object"
|
||||
) {
|
||||
setPullFilterPreferences(prefs, body.repository, body.pullFilter);
|
||||
}
|
||||
if (body.explorer && typeof body.explorer === "object") {
|
||||
setExplorerPreferences(prefs, body.explorer);
|
||||
}
|
||||
if (body.player && typeof body.player === "object") {
|
||||
setPlayerPreferences(prefs, body.player);
|
||||
}
|
||||
return sendJson(res, 200, await savePrefs(prefs));
|
||||
}
|
||||
|
||||
if (req.method === "GET" && path === "/api/pulls") {
|
||||
const repository = normalizeRepository(url.searchParams.get("repo"));
|
||||
const state = url.searchParams.get("state") ?? "open";
|
||||
const query = url.searchParams.get("q") ?? "";
|
||||
const artifacts = url.searchParams.get("artifacts") ?? "all";
|
||||
const ci = url.searchParams.get("ci") ?? "all";
|
||||
const { auth } = await resolveAuth(repository);
|
||||
const cacheKey = pullRequestCacheKey(auth.active, "list", [
|
||||
repository,
|
||||
state,
|
||||
query,
|
||||
artifacts,
|
||||
ci,
|
||||
]);
|
||||
const phase = url.searchParams.get("phase");
|
||||
const force = url.searchParams.get("refresh") === "1";
|
||||
if (phase) {
|
||||
if (!["initial", "batch", "details"].includes(phase)) {
|
||||
throw new HttpError(400, "Unknown progressive pull request phase.");
|
||||
}
|
||||
if (phase === "initial") {
|
||||
const cached = force ? undefined : pullRequestCache.peek(cacheKey);
|
||||
if (cached) {
|
||||
return sendJson(res, 200, {
|
||||
repository,
|
||||
...cached,
|
||||
account: auth.active,
|
||||
progressive: {
|
||||
batchSize: PROGRESSIVE_PULL_BATCH_SIZE,
|
||||
complete: true,
|
||||
detailedCount: cached.evaluatedCount,
|
||||
loadedCount: cached.pulls.length,
|
||||
offset: 0,
|
||||
sourceCount: cached.evaluatedCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (force) pullRequestCache.delete(cacheKey);
|
||||
const session = await progressivePullRequestCache.get(
|
||||
cacheKey,
|
||||
async () => ({
|
||||
id: randomUUID(),
|
||||
base: await searchPullRequestBase(
|
||||
auth.activeToken,
|
||||
repository,
|
||||
query,
|
||||
state,
|
||||
{ artifacts, ci },
|
||||
{ perPage: 100 },
|
||||
),
|
||||
completedNumbers: new Set(),
|
||||
detailPromises: new Map(),
|
||||
detailResults: new Map(),
|
||||
enrichedPulls: new Map(),
|
||||
}),
|
||||
{ force },
|
||||
);
|
||||
if (!session.base.pulls.length) {
|
||||
if (progressivePullRequestCache.peek(cacheKey) !== session) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
"The progressive pull request load expired. Refresh the list.",
|
||||
);
|
||||
}
|
||||
const payload = completeProgressivePullPayload(session);
|
||||
pullRequestCache.set(cacheKey, payload);
|
||||
return sendJson(res, 200, {
|
||||
repository,
|
||||
...payload,
|
||||
account: auth.active,
|
||||
});
|
||||
}
|
||||
return sendJson(res, 200, {
|
||||
repository,
|
||||
...progressivePullPayload(
|
||||
session,
|
||||
session.base.pulls.slice(0, PROGRESSIVE_PULL_BATCH_SIZE),
|
||||
),
|
||||
account: auth.active,
|
||||
});
|
||||
}
|
||||
|
||||
const session = progressivePullRequestCache.peek(cacheKey);
|
||||
const requestedSessionId = url.searchParams.get("session");
|
||||
if (!session || !requestedSessionId || session.id !== requestedSessionId) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
"The progressive pull request load expired. Refresh the list.",
|
||||
);
|
||||
}
|
||||
const offset = Number.parseInt(url.searchParams.get("offset"), 10);
|
||||
if (
|
||||
!Number.isSafeInteger(offset) ||
|
||||
offset < 0 ||
|
||||
offset >= session.base.pulls.length ||
|
||||
offset % PROGRESSIVE_PULL_BATCH_SIZE !== 0
|
||||
) {
|
||||
throw new HttpError(400, "A valid progressive batch offset is required.");
|
||||
}
|
||||
if (phase === "batch") {
|
||||
return sendJson(res, 200, {
|
||||
repository,
|
||||
...progressivePullPayload(
|
||||
session,
|
||||
session.base.pulls.slice(
|
||||
offset,
|
||||
offset + PROGRESSIVE_PULL_BATCH_SIZE,
|
||||
),
|
||||
{ offset },
|
||||
),
|
||||
account: auth.active,
|
||||
});
|
||||
}
|
||||
const details = await loadProgressivePullDetails(
|
||||
session,
|
||||
cacheKey,
|
||||
auth.activeToken,
|
||||
repository,
|
||||
offset,
|
||||
);
|
||||
if (progressivePullRequestCache.peek(cacheKey) !== session) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
"The progressive pull request load expired. Refresh the list.",
|
||||
);
|
||||
}
|
||||
return sendJson(res, 200, {
|
||||
repository,
|
||||
...details,
|
||||
account: auth.active,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await pullRequestCache.get(
|
||||
cacheKey,
|
||||
() =>
|
||||
searchPullRequests(
|
||||
auth.activeToken,
|
||||
repository,
|
||||
query,
|
||||
state,
|
||||
{ artifacts, ci },
|
||||
),
|
||||
{ force },
|
||||
);
|
||||
return sendJson(res, 200, {
|
||||
repository,
|
||||
...result,
|
||||
account: auth.active,
|
||||
});
|
||||
}
|
||||
|
||||
const pullMatch = path.match(/^\/api\/pulls\/(\d+)$/);
|
||||
if (req.method === "GET" && pullMatch) {
|
||||
const repository = normalizeRepository(url.searchParams.get("repo"));
|
||||
const { auth } = await resolveAuth(repository);
|
||||
const payload = await pullRequestCache.get(
|
||||
pullRequestCacheKey(auth.active, "detail", [repository, pullMatch[1]]),
|
||||
() =>
|
||||
listPullRequestArtifacts(
|
||||
auth.activeToken,
|
||||
repository,
|
||||
pullMatch[1],
|
||||
),
|
||||
{ force: url.searchParams.get("refresh") === "1" },
|
||||
);
|
||||
return sendJson(res, 200, {
|
||||
repository,
|
||||
...payload,
|
||||
account: auth.active,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "POST" && path === "/api/artifacts/inspect") {
|
||||
const body = await readJsonBody(req);
|
||||
const repository = normalizeRepository(body.repository);
|
||||
const { auth } = await resolveAuth(repository);
|
||||
const metadata = await inspectArtifact(
|
||||
auth.activeToken,
|
||||
repository,
|
||||
body.artifactId,
|
||||
{
|
||||
onProgress: (progress) =>
|
||||
broadcast(entry, "artifact-progress", { repository, ...progress }),
|
||||
},
|
||||
);
|
||||
broadcast(entry, "cache", await getCacheSummary());
|
||||
return sendJson(res, 200, metadata);
|
||||
}
|
||||
|
||||
const artifactMatch = parseArtifactRoute(path);
|
||||
if (req.method === "GET" && artifactMatch) {
|
||||
const metadata = await getCachedArtifact(artifactMatch[1]);
|
||||
if (!metadata) throw new HttpError(404, "Artifact is not cached.");
|
||||
return sendJson(res, 200, metadata);
|
||||
}
|
||||
|
||||
const textMatch = path.match(/^\/api\/artifacts\/(\d+)\/text$/);
|
||||
if (req.method === "GET" && textMatch) {
|
||||
const entryPath = url.searchParams.get("path");
|
||||
if (!entryPath) throw new HttpError(400, "File path is required.");
|
||||
const kind = kindForPath(entryPath);
|
||||
if (!isInlineTextKind(kind)) {
|
||||
throw new HttpError(415, "This file is not an inline text preview.");
|
||||
}
|
||||
const context = await readCachedEntry(
|
||||
textMatch[1],
|
||||
entryPath,
|
||||
kind === "trx" ? MAX_TRX_PREVIEW_BYTES : MAX_INLINE_PREVIEW_BYTES,
|
||||
);
|
||||
return sendJson(res, 200, {
|
||||
path: context.entry.name,
|
||||
kind,
|
||||
content: context.content.toString("utf8"),
|
||||
});
|
||||
}
|
||||
|
||||
const previewMatch = path.match(/^\/api\/artifacts\/(\d+)\/preview-url$/);
|
||||
if (req.method === "GET" && previewMatch) {
|
||||
const entryPath = url.searchParams.get("path");
|
||||
if (!entryPath) throw new HttpError(400, "File path is required.");
|
||||
if (kindForPath(entryPath) !== "html") {
|
||||
throw new HttpError(415, "Only HTML files can use the site preview.");
|
||||
}
|
||||
const metadata = await getCachedArtifact(previewMatch[1], { touch: false });
|
||||
if (!metadata) throw new HttpError(404, "Artifact is not cached. Inspect it first.");
|
||||
if (!hasRootIndexHtml(metadata.analysis?.entries ?? [])) {
|
||||
throw new HttpError(
|
||||
415,
|
||||
"HTML previews require index.html at the artifact root.",
|
||||
);
|
||||
}
|
||||
const theme = url.searchParams.get("theme");
|
||||
if (theme !== "light" && theme !== "dark") {
|
||||
throw new HttpError(400, "Preview theme must be light or dark.");
|
||||
}
|
||||
return sendJson(res, 200, {
|
||||
url: await startStaticPreview(previewMatch[1], entryPath, {
|
||||
theme,
|
||||
parentOrigin: new URL(entry.url).origin,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && path === "/api/cache") {
|
||||
return sendJson(res, 200, await getCacheSummary());
|
||||
}
|
||||
|
||||
const cacheMatch = path.match(/^\/api\/cache\/(\d+)$/);
|
||||
if (req.method === "DELETE" && cacheMatch) {
|
||||
await stopStaticPreviewsForArtifact(cacheMatch[1]);
|
||||
const result = await deleteCachedArtifact(cacheMatch[1]);
|
||||
broadcast(entry, "cache", await getCacheSummary());
|
||||
return sendJson(res, 200, result);
|
||||
}
|
||||
|
||||
if (req.method === "DELETE" && path === "/api/cache") {
|
||||
await stopAllStaticPreviews();
|
||||
const result = await clearArtifactCache();
|
||||
broadcast(entry, "cache", await getCacheSummary());
|
||||
return sendJson(res, 200, result);
|
||||
}
|
||||
|
||||
if (req.method === "GET" && path === "/events") {
|
||||
res.writeHead(200, {
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"Content-Type": "text/event-stream",
|
||||
});
|
||||
res.write(": connected\n\n");
|
||||
entry.sseClients.add(res);
|
||||
req.on("close", () => entry.sseClients.delete(res));
|
||||
return;
|
||||
}
|
||||
|
||||
throw new HttpError(404, "API route not found.");
|
||||
}
|
||||
|
||||
async function handleRequest(req, res, entry) {
|
||||
const url = new URL(req.url, "http://127.0.0.1");
|
||||
try {
|
||||
if (!isCanonicalHost(req, entry.host)) {
|
||||
throw new HttpError(403, "Request host is not allowed.");
|
||||
}
|
||||
const protectedRoute = requiresCapabilityToken(url.pathname);
|
||||
if (protectedRoute && isCrossSiteRequest(req, entry.origin)) {
|
||||
throw new HttpError(403, "Cross-site requests are not allowed.");
|
||||
}
|
||||
if (protectedRoute && !hasCapabilityToken(req, url, entry.token)) {
|
||||
throw new HttpError(403, "Missing or invalid capability token.");
|
||||
}
|
||||
|
||||
const documentRoute =
|
||||
req.method === "GET" &&
|
||||
(url.pathname === "/" || url.pathname === "/index.html");
|
||||
if (documentRoute && url.searchParams.get("token") !== entry.token) {
|
||||
throw new HttpError(403, "Missing or invalid capability token.");
|
||||
}
|
||||
if (documentRoute) {
|
||||
return sendText(res, 200, renderHtml(entry.token), "text/html; charset=utf-8", {
|
||||
"Content-Security-Policy": MAIN_CSP,
|
||||
"Referrer-Policy": "no-referrer",
|
||||
});
|
||||
}
|
||||
|
||||
const assetTypes = new Map([
|
||||
["/assets/primer-color-modes.css", ["primer-color-modes.css", "text/css; charset=utf-8"]],
|
||||
["/assets/primer-core.css", ["primer-core.css", "text/css; charset=utf-8"]],
|
||||
["/assets/primer-product.css", ["primer-product.css", "text/css; charset=utf-8"]],
|
||||
["/assets/asciinema-player.css", ["asciinema-player.css", "text/css; charset=utf-8"]],
|
||||
["/assets/asciinema-player.min.js", ["asciinema-player.min.js", "text/javascript; charset=utf-8"]],
|
||||
["/assets/asciinema-player-worker.min.js", ["asciinema-player-worker.min.js", "text/javascript; charset=utf-8"]],
|
||||
["/assets/app.css", ["app.css", "text/css; charset=utf-8"]],
|
||||
["/assets/trx-preview.js", ["trx-preview.js", "text/javascript; charset=utf-8"]],
|
||||
["/assets/app.js", ["app.js", "text/javascript; charset=utf-8"]],
|
||||
]);
|
||||
if (req.method === "GET" && assetTypes.has(url.pathname)) {
|
||||
const [name, type] = assetTypes.get(url.pathname);
|
||||
return await sendFile(res, join(ASSET_ROOT, name), type);
|
||||
}
|
||||
const iconMatch = url.pathname.match(/^\/assets\/octicons\/([a-z-]+)\.svg$/);
|
||||
if (req.method === "GET" && iconMatch) {
|
||||
return await sendFile(
|
||||
res,
|
||||
join(ASSET_ROOT, "octicons", `${iconMatch[1]}.svg`),
|
||||
"image/svg+xml",
|
||||
);
|
||||
}
|
||||
|
||||
if (await handleContentRequest(req, res, url.pathname, url.searchParams)) return;
|
||||
if (url.pathname.startsWith("/api/") || url.pathname === "/events") {
|
||||
return await handleApi(req, res, entry, url);
|
||||
}
|
||||
throw new HttpError(404, "Route not found.");
|
||||
} catch (error) {
|
||||
entry.log?.(`${req.method} ${url.pathname}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
if (res.headersSent) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const status =
|
||||
error instanceof HttpError
|
||||
? error.status
|
||||
: error instanceof GitHubApiError
|
||||
? error.status
|
||||
: 500;
|
||||
sendJson(res, status, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function startInstance(instanceId, initialInput, log) {
|
||||
let entry = servers.get(instanceId);
|
||||
if (entry) {
|
||||
entry.initialInput = initialInput ?? entry.initialInput;
|
||||
return entry;
|
||||
}
|
||||
|
||||
entry = {
|
||||
host: null,
|
||||
initialInput: initialInput ?? null,
|
||||
log,
|
||||
origin: null,
|
||||
server: null,
|
||||
sseClients: new Set(),
|
||||
token: createCapabilityToken(),
|
||||
url: null,
|
||||
};
|
||||
const server = createServer((req, res) => {
|
||||
void handleRequest(req, res, entry);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
throw new Error("Canvas server did not receive a TCP port.");
|
||||
}
|
||||
entry.server = server;
|
||||
entry.host = `127.0.0.1:${address.port}`;
|
||||
entry.origin = `http://${entry.host}`;
|
||||
entry.url = `${entry.origin}/?token=${encodeURIComponent(entry.token)}`;
|
||||
servers.set(instanceId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export async function stopInstance(instanceId) {
|
||||
const entry = servers.get(instanceId);
|
||||
if (!entry) return;
|
||||
servers.delete(instanceId);
|
||||
for (const response of entry.sseClients) response.end();
|
||||
if (entry.url) {
|
||||
await stopStaticPreviewsForOrigin(new URL(entry.url).origin);
|
||||
}
|
||||
await new Promise((resolve) => entry.server.close(resolve));
|
||||
}
|
||||
|
||||
export function navigateInstance(instanceId, route) {
|
||||
const entry = servers.get(instanceId);
|
||||
if (!entry) return false;
|
||||
broadcast(entry, "navigate", { route });
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function broadcastCache({ refresh = false } = {}) {
|
||||
const summary = await getCacheSummary();
|
||||
for (const entry of servers.values()) {
|
||||
broadcast(entry, "cache", summary);
|
||||
if (refresh) broadcast(entry, "refresh", { reason: "cache" });
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeAllPreviews() {
|
||||
await stopAllStaticPreviews();
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { PREFS_FILE } from "./constants.mjs";
|
||||
|
||||
const MAX_RECENT_REPOSITORIES = 12;
|
||||
const MAX_FAVORITE_REPOSITORIES = 3;
|
||||
const MAX_PULL_FILTER_REPOSITORIES = 50;
|
||||
const PLAYER_FONT_SIZES = new Set(["12px", "14px", "16px", "18px", "20px", "22px"]);
|
||||
const PLAYER_FONTS = new Set(["system", "cascadia", "consolas"]);
|
||||
const PLAYER_SPEEDS = new Set([0.5, 1, 1.5, 2]);
|
||||
const PULL_ARTIFACT_FILTERS = new Set(["all", "with", "without"]);
|
||||
const PULL_CI_FILTERS = new Set(["all", "failing", "passing", "pending", "none"]);
|
||||
let preferenceSaveQueue = Promise.resolve();
|
||||
let preferenceSaveSequence = 0;
|
||||
const WINDOWS_RENAME_RETRY_CODES = new Set(["EACCES", "EBUSY", "EPERM"]);
|
||||
|
||||
export const DEFAULT_PREFS = Object.freeze({
|
||||
account: null,
|
||||
repository: "microsoft/aspire",
|
||||
pullState: "open",
|
||||
repositories: {
|
||||
pinned: null,
|
||||
favorites: [],
|
||||
recent: ["microsoft/aspire"],
|
||||
},
|
||||
pullFilters: {},
|
||||
explorer: {
|
||||
sidebarCollapsed: false,
|
||||
},
|
||||
player: {
|
||||
fontSize: "16px",
|
||||
fontFamily: "system",
|
||||
lineHeight: 1.2,
|
||||
speed: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export function normalizeRepository(value) {
|
||||
const repository = String(value ?? "").trim().replace(/^https?:\/\/github\.com\//i, "");
|
||||
const match = repository.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/);
|
||||
if (!match) {
|
||||
throw new Error("Repository must use the owner/name format.");
|
||||
}
|
||||
if ([match[1], match[2]].some((part) => part === "." || part === "..")) {
|
||||
throw new Error("Repository owner and name cannot be dot segments.");
|
||||
}
|
||||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
|
||||
function normalizeStoredRepository(value) {
|
||||
try {
|
||||
return typeof value === "string" && value.trim() ? normalizeRepository(value) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRepositoryList(value, limit) {
|
||||
const repositories = [];
|
||||
for (const item of Array.isArray(value) ? value : []) {
|
||||
const repository = normalizeStoredRepository(item);
|
||||
if (repository && !repositories.includes(repository)) repositories.push(repository);
|
||||
if (repositories.length === limit) break;
|
||||
}
|
||||
return repositories;
|
||||
}
|
||||
|
||||
function normalizePlayer(value) {
|
||||
const lineHeight = Number(value?.lineHeight);
|
||||
const speed = Number(value?.speed);
|
||||
return {
|
||||
fontSize: PLAYER_FONT_SIZES.has(value?.fontSize)
|
||||
? value.fontSize
|
||||
: DEFAULT_PREFS.player.fontSize,
|
||||
fontFamily: PLAYER_FONTS.has(value?.fontFamily)
|
||||
? value.fontFamily
|
||||
: DEFAULT_PREFS.player.fontFamily,
|
||||
lineHeight:
|
||||
Number.isFinite(lineHeight) && lineHeight >= 1 && lineHeight <= 1.6
|
||||
? Math.round(lineHeight * 10) / 10
|
||||
: DEFAULT_PREFS.player.lineHeight,
|
||||
speed: PLAYER_SPEEDS.has(speed) ? speed : DEFAULT_PREFS.player.speed,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeExplorer(value) {
|
||||
return {
|
||||
sidebarCollapsed: value?.sidebarCollapsed === true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePullFilter(value) {
|
||||
const author =
|
||||
typeof value?.author === "string" && value.author.trim()
|
||||
? value.author.trim().slice(0, 100)
|
||||
: null;
|
||||
const updatedAt = Number(value?.updatedAt);
|
||||
return {
|
||||
state: ["open", "closed", "all"].includes(value?.state) ? value.state : "open",
|
||||
author,
|
||||
artifacts: PULL_ARTIFACT_FILTERS.has(value?.artifacts) ? value.artifacts : "all",
|
||||
ci: PULL_CI_FILTERS.has(value?.ci) ? value.ci : "all",
|
||||
updatedAt: Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePullFilters(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
const entries = [];
|
||||
for (const [key, filter] of Object.entries(value)) {
|
||||
const repository = normalizeStoredRepository(key);
|
||||
if (!repository || !filter || typeof filter !== "object") continue;
|
||||
entries.push([
|
||||
repository.toLocaleLowerCase(),
|
||||
normalizePullFilter(filter),
|
||||
]);
|
||||
}
|
||||
return Object.fromEntries(
|
||||
entries
|
||||
.sort((left, right) => right[1].updatedAt - left[1].updatedAt)
|
||||
.slice(0, MAX_PULL_FILTER_REPOSITORIES),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizePrefs(value) {
|
||||
const prefs = value && typeof value === "object" ? value : {};
|
||||
const repository =
|
||||
normalizeStoredRepository(prefs.repository) ?? DEFAULT_PREFS.repository;
|
||||
const pinned = normalizeStoredRepository(prefs.repositories?.pinned);
|
||||
const favorites = normalizeRepositoryList(
|
||||
prefs.repositories?.favorites,
|
||||
MAX_FAVORITE_REPOSITORIES,
|
||||
).filter((item) => item !== pinned);
|
||||
return {
|
||||
account: typeof prefs.account === "string" && prefs.account ? prefs.account : null,
|
||||
repository,
|
||||
pullState: ["open", "closed", "all"].includes(prefs.pullState)
|
||||
? prefs.pullState
|
||||
: DEFAULT_PREFS.pullState,
|
||||
repositories: {
|
||||
pinned,
|
||||
favorites,
|
||||
recent: normalizeRepositoryList(
|
||||
[
|
||||
repository,
|
||||
...(Array.isArray(prefs.repositories?.recent)
|
||||
? prefs.repositories.recent
|
||||
: []),
|
||||
],
|
||||
MAX_RECENT_REPOSITORIES,
|
||||
),
|
||||
},
|
||||
pullFilters: normalizePullFilters(prefs.pullFilters),
|
||||
explorer: normalizeExplorer(prefs.explorer),
|
||||
player: normalizePlayer(prefs.player),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadPrefs() {
|
||||
try {
|
||||
return normalizePrefs(JSON.parse(await readFile(PREFS_FILE, "utf8")));
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
return normalizePrefs();
|
||||
}
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error(`Preferences are not valid JSON: ${PREFS_FILE}`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function replacePreferencesFile(temporary) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < 7; attempt++) {
|
||||
try {
|
||||
await rename(temporary, PREFS_FILE);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!WINDOWS_RENAME_RETRY_CODES.has(error?.code)) throw error;
|
||||
lastError = error;
|
||||
if (attempt === 6) break;
|
||||
await delay(10 * 2 ** attempt);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await rm(temporary, { force: true });
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError(
|
||||
[lastError, cleanupError],
|
||||
"Preferences could not be replaced or cleaned up.",
|
||||
);
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export async function savePrefs(value) {
|
||||
const prefs = normalizePrefs(value);
|
||||
const save = async () => {
|
||||
await mkdir(dirname(PREFS_FILE), { recursive: true });
|
||||
const temporary =
|
||||
`${PREFS_FILE}.${process.pid}.${++preferenceSaveSequence}.tmp`;
|
||||
await writeFile(temporary, `${JSON.stringify(prefs, null, 2)}\n`, "utf8");
|
||||
await replacePreferencesFile(temporary);
|
||||
return prefs;
|
||||
};
|
||||
const pending = preferenceSaveQueue.then(save, save);
|
||||
preferenceSaveQueue = pending.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return pending;
|
||||
}
|
||||
|
||||
export function rememberRepository(preferences, value) {
|
||||
const repository = normalizeRepository(value);
|
||||
preferences.repository = repository;
|
||||
preferences.repositories ??= {};
|
||||
preferences.repositories.recent = normalizeRepositoryList(
|
||||
[
|
||||
repository,
|
||||
...(Array.isArray(preferences.repositories.recent)
|
||||
? preferences.repositories.recent
|
||||
: []),
|
||||
],
|
||||
MAX_RECENT_REPOSITORIES,
|
||||
);
|
||||
return repository;
|
||||
}
|
||||
|
||||
export function setPinnedRepository(preferences, value) {
|
||||
const repository = value == null || value === "" ? null : normalizeRepository(value);
|
||||
preferences.repositories ??= {};
|
||||
preferences.repositories.pinned = repository;
|
||||
preferences.repositories.favorites = normalizeRepositoryList(
|
||||
preferences.repositories.favorites,
|
||||
MAX_FAVORITE_REPOSITORIES,
|
||||
).filter((item) => item !== repository);
|
||||
return repository;
|
||||
}
|
||||
|
||||
export function setFavoriteRepository(preferences, value, favorite) {
|
||||
const repository = normalizeRepository(value);
|
||||
preferences.repositories ??= {};
|
||||
const favorites = normalizeRepositoryList(
|
||||
preferences.repositories.favorites,
|
||||
MAX_FAVORITE_REPOSITORIES,
|
||||
).filter((item) => item !== repository);
|
||||
if (favorite) {
|
||||
if (preferences.repositories.pinned === repository) {
|
||||
throw new Error("The pinned repository is already available in quick switch.");
|
||||
}
|
||||
if (favorites.length >= MAX_FAVORITE_REPOSITORIES) {
|
||||
throw new Error("You can favorite up to three repositories.");
|
||||
}
|
||||
favorites.unshift(repository);
|
||||
}
|
||||
preferences.repositories.favorites = favorites;
|
||||
return favorites;
|
||||
}
|
||||
|
||||
export function setPullFilterPreferences(preferences, repositoryValue, value) {
|
||||
const repository = normalizeRepository(repositoryValue).toLocaleLowerCase();
|
||||
preferences.pullFilters = normalizePullFilters({
|
||||
...preferences.pullFilters,
|
||||
[repository]: {
|
||||
...preferences.pullFilters?.[repository],
|
||||
...value,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
return preferences.pullFilters[repository];
|
||||
}
|
||||
|
||||
export function setExplorerPreferences(preferences, value) {
|
||||
preferences.explorer = normalizeExplorer({
|
||||
...preferences.explorer,
|
||||
...value,
|
||||
});
|
||||
return preferences.explorer;
|
||||
}
|
||||
|
||||
export function setPlayerPreferences(preferences, value) {
|
||||
preferences.player = normalizePlayer({
|
||||
...preferences.player,
|
||||
...value,
|
||||
});
|
||||
return preferences.player;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "{}"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2011-2021 Marcin Kulik
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 GitHub Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2021 GitHub Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,401 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { open, stat } from "node:fs/promises";
|
||||
import { posix } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { Transform } from "node:stream";
|
||||
import { createInflateRaw } from "node:zlib";
|
||||
import {
|
||||
MAX_CENTRAL_DIRECTORY_BYTES,
|
||||
MAX_STREAMED_ENTRY_BYTES,
|
||||
MAX_ZIP_ENTRIES,
|
||||
} from "./constants.mjs";
|
||||
|
||||
const EOCD_SIGNATURE = 0x06054b50;
|
||||
const CENTRAL_SIGNATURE = 0x02014b50;
|
||||
const LOCAL_SIGNATURE = 0x04034b50;
|
||||
const ZIP64_U16 = 0xffff;
|
||||
const ZIP64_U32 = 0xffffffff;
|
||||
|
||||
const crcTable = new Uint32Array(256);
|
||||
for (let index = 0; index < 256; index++) {
|
||||
let value = index;
|
||||
for (let bit = 0; bit < 8; bit++) {
|
||||
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
||||
}
|
||||
crcTable[index] = value >>> 0;
|
||||
}
|
||||
|
||||
function crc32(buffer) {
|
||||
let value = 0xffffffff;
|
||||
for (const byte of buffer) {
|
||||
value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8);
|
||||
}
|
||||
return (value ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
async function readExactly(handle, length, position) {
|
||||
const buffer = Buffer.allocUnsafe(length);
|
||||
let offset = 0;
|
||||
while (offset < length) {
|
||||
const result = await handle.read(buffer, offset, length - offset, position + offset);
|
||||
if (result.bytesRead === 0) {
|
||||
throw new Error("ZIP archive ended unexpectedly.");
|
||||
}
|
||||
offset += result.bytesRead;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function safeEntryPath(rawName) {
|
||||
if (!rawName || rawName.includes("\0")) {
|
||||
throw new Error("ZIP archive contains an invalid file name.");
|
||||
}
|
||||
const slashed = rawName.replaceAll("\\", "/").replace(/^\.\/+/, "");
|
||||
const normalized = posix.normalize(slashed);
|
||||
if (
|
||||
normalized === "." ||
|
||||
normalized === ".." ||
|
||||
normalized.startsWith("../") ||
|
||||
normalized.startsWith("/") ||
|
||||
/^[A-Za-z]:\//.test(normalized)
|
||||
) {
|
||||
throw new Error(`ZIP archive contains an unsafe path: ${rawName}`);
|
||||
}
|
||||
if (normalized.length > 2_048) {
|
||||
throw new Error("ZIP archive contains an excessively long file name.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function dosDateTime(date, time) {
|
||||
if (!date) return null;
|
||||
const year = 1980 + ((date >> 9) & 0x7f);
|
||||
const month = (date >> 5) & 0x0f;
|
||||
const day = date & 0x1f;
|
||||
const hour = (time >> 11) & 0x1f;
|
||||
const minute = (time >> 5) & 0x3f;
|
||||
const second = (time & 0x1f) * 2;
|
||||
const value = new Date(Date.UTC(year, Math.max(0, month - 1), day, hour, minute, second));
|
||||
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
||||
}
|
||||
|
||||
function decodeName(buffer, utf8) {
|
||||
// GitHub-generated artifacts use UTF-8. For legacy archives, decoding as
|
||||
// latin1 preserves a stable byte-for-byte lookup instead of replacing bytes.
|
||||
return buffer.toString(utf8 ? "utf8" : "latin1");
|
||||
}
|
||||
|
||||
export async function readZipIndex(zipPath) {
|
||||
const archive = await stat(zipPath);
|
||||
if (!archive.isFile() || archive.size < 22) {
|
||||
throw new Error("Artifact is not a valid ZIP archive.");
|
||||
}
|
||||
|
||||
const handle = await open(zipPath, "r");
|
||||
try {
|
||||
const tailLength = Math.min(archive.size, 65_557);
|
||||
const tailOffset = archive.size - tailLength;
|
||||
const tail = await readExactly(handle, tailLength, tailOffset);
|
||||
let eocdOffset = -1;
|
||||
for (let index = tail.length - 22; index >= 0; index--) {
|
||||
if (tail.readUInt32LE(index) === EOCD_SIGNATURE) {
|
||||
if (index + 22 <= tail.length) {
|
||||
const commentLength = tail.readUInt16LE(index + 20);
|
||||
if (index + 22 + commentLength === tail.length) {
|
||||
eocdOffset = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (eocdOffset < 0) {
|
||||
throw new Error("ZIP end-of-directory record was not found.");
|
||||
}
|
||||
|
||||
const disk = tail.readUInt16LE(eocdOffset + 4);
|
||||
const centralDisk = tail.readUInt16LE(eocdOffset + 6);
|
||||
const entriesOnDisk = tail.readUInt16LE(eocdOffset + 8);
|
||||
const entryCount = tail.readUInt16LE(eocdOffset + 10);
|
||||
const centralSize = tail.readUInt32LE(eocdOffset + 12);
|
||||
const centralOffset = tail.readUInt32LE(eocdOffset + 16);
|
||||
if (disk !== 0 || centralDisk !== 0 || entriesOnDisk !== entryCount) {
|
||||
throw new Error("Multi-disk ZIP artifacts are not supported.");
|
||||
}
|
||||
if (
|
||||
entryCount === ZIP64_U16 ||
|
||||
centralSize === ZIP64_U32 ||
|
||||
centralOffset === ZIP64_U32
|
||||
) {
|
||||
throw new Error("ZIP64 artifacts larger than 4 GiB are not supported.");
|
||||
}
|
||||
if (entryCount > MAX_ZIP_ENTRIES) {
|
||||
throw new Error(`ZIP contains too many entries (${entryCount.toLocaleString()}).`);
|
||||
}
|
||||
if (centralSize > MAX_CENTRAL_DIRECTORY_BYTES) {
|
||||
throw new Error("ZIP central directory exceeds the 64 MiB safety limit.");
|
||||
}
|
||||
if (centralOffset + centralSize > archive.size) {
|
||||
throw new Error("ZIP central directory points outside the archive.");
|
||||
}
|
||||
|
||||
const central = await readExactly(handle, centralSize, centralOffset);
|
||||
const entries = [];
|
||||
let cursor = 0;
|
||||
while (cursor < central.length) {
|
||||
if (central.length - cursor < 46 || central.readUInt32LE(cursor) !== CENTRAL_SIGNATURE) {
|
||||
throw new Error("ZIP central directory is malformed.");
|
||||
}
|
||||
const flags = central.readUInt16LE(cursor + 8);
|
||||
const method = central.readUInt16LE(cursor + 10);
|
||||
const modifiedTime = central.readUInt16LE(cursor + 12);
|
||||
const modifiedDate = central.readUInt16LE(cursor + 14);
|
||||
const expectedCrc32 = central.readUInt32LE(cursor + 16);
|
||||
const compressedSize = central.readUInt32LE(cursor + 20);
|
||||
const uncompressedSize = central.readUInt32LE(cursor + 24);
|
||||
const nameLength = central.readUInt16LE(cursor + 28);
|
||||
const extraLength = central.readUInt16LE(cursor + 30);
|
||||
const commentLength = central.readUInt16LE(cursor + 32);
|
||||
const diskStart = central.readUInt16LE(cursor + 34);
|
||||
const externalAttributes = central.readUInt32LE(cursor + 38);
|
||||
const localHeaderOffset = central.readUInt32LE(cursor + 42);
|
||||
const recordLength = 46 + nameLength + extraLength + commentLength;
|
||||
if (cursor + recordLength > central.length) {
|
||||
throw new Error("ZIP central directory entry is truncated.");
|
||||
}
|
||||
if (
|
||||
compressedSize === ZIP64_U32 ||
|
||||
uncompressedSize === ZIP64_U32 ||
|
||||
localHeaderOffset === ZIP64_U32 ||
|
||||
diskStart === ZIP64_U16
|
||||
) {
|
||||
throw new Error("ZIP64 entries are not supported.");
|
||||
}
|
||||
|
||||
const rawName = decodeName(
|
||||
central.subarray(cursor + 46, cursor + 46 + nameLength),
|
||||
Boolean(flags & 0x0800),
|
||||
);
|
||||
const name = safeEntryPath(rawName);
|
||||
const unixMode = externalAttributes >>> 16;
|
||||
const directory = name.endsWith("/");
|
||||
const symlink = (unixMode & 0o170000) === 0o120000;
|
||||
const encrypted = Boolean(flags & 0x0001);
|
||||
entries.push({
|
||||
name,
|
||||
directory,
|
||||
symlink,
|
||||
encrypted,
|
||||
supported: !directory && !symlink && !encrypted && (method === 0 || method === 8),
|
||||
method,
|
||||
flags,
|
||||
crc32: expectedCrc32,
|
||||
compressedSize,
|
||||
uncompressedSize,
|
||||
localHeaderOffset,
|
||||
modifiedAt: dosDateTime(modifiedDate, modifiedTime),
|
||||
});
|
||||
cursor += recordLength;
|
||||
}
|
||||
if (entries.length !== entryCount) {
|
||||
throw new Error(
|
||||
`ZIP entry count mismatch. Expected ${entryCount}, found ${entries.length}.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
path: zipPath,
|
||||
size: archive.size,
|
||||
entries,
|
||||
totalUncompressedBytes: entries.reduce(
|
||||
(total, entry) => total + (entry.directory ? 0 : entry.uncompressedSize),
|
||||
0,
|
||||
),
|
||||
};
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function entryDataRange(zipPath, entry) {
|
||||
const handle = await open(zipPath, "r");
|
||||
try {
|
||||
const header = await readExactly(handle, 30, entry.localHeaderOffset);
|
||||
if (header.readUInt32LE(0) !== LOCAL_SIGNATURE) {
|
||||
throw new Error(`ZIP local header is missing for ${entry.name}.`);
|
||||
}
|
||||
const nameLength = header.readUInt16LE(26);
|
||||
const extraLength = header.readUInt16LE(28);
|
||||
const start = entry.localHeaderOffset + 30 + nameLength + extraLength;
|
||||
const endExclusive = start + entry.compressedSize;
|
||||
const archive = await stat(zipPath);
|
||||
if (endExclusive > archive.size) {
|
||||
throw new Error(`ZIP entry data points outside the archive: ${entry.name}`);
|
||||
}
|
||||
return { start, endExclusive };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function findZipEntry(index, requestedName) {
|
||||
const normalized = safeEntryPath(String(requestedName ?? ""));
|
||||
return index.entries.find((entry) => entry.name === normalized) ?? null;
|
||||
}
|
||||
|
||||
export async function readZipEntry(zipPath, entry, maxBytes) {
|
||||
if (!entry?.supported) {
|
||||
throw new Error(`ZIP entry cannot be previewed: ${entry?.name ?? "unknown"}`);
|
||||
}
|
||||
if (entry.uncompressedSize > maxBytes) {
|
||||
throw new Error(
|
||||
`ZIP entry is too large for an inline preview (${entry.uncompressedSize.toLocaleString()} bytes).`,
|
||||
);
|
||||
}
|
||||
const { start } = await entryDataRange(zipPath, entry);
|
||||
const handle = await open(zipPath, "r");
|
||||
let compressed;
|
||||
try {
|
||||
compressed = await readExactly(handle, entry.compressedSize, start);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
let output;
|
||||
if (entry.method === 0) {
|
||||
output = compressed;
|
||||
} else {
|
||||
const cap = entry.uncompressedSize;
|
||||
output = await new Promise((resolve, reject) => {
|
||||
const inflate = createInflateRaw();
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
inflate.on("data", (chunk) => {
|
||||
total += chunk.length;
|
||||
if (total > cap) {
|
||||
inflate.destroy(new Error(`ZIP entry decompressed past declared size: ${entry.name}`));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
inflate.once("end", () => resolve(Buffer.concat(chunks)));
|
||||
inflate.once("error", reject);
|
||||
inflate.end(compressed);
|
||||
});
|
||||
}
|
||||
if (output.length !== entry.uncompressedSize) {
|
||||
throw new Error(`ZIP entry size check failed: ${entry.name}`);
|
||||
}
|
||||
if (crc32(output) !== entry.crc32) {
|
||||
throw new Error(`ZIP entry checksum failed: ${entry.name}`);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function createVerifyTransform(entry) {
|
||||
const cap = Math.min(entry.uncompressedSize, MAX_STREAMED_ENTRY_BYTES);
|
||||
let total = 0;
|
||||
let crcValue = 0xffffffff;
|
||||
return new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
total += chunk.length;
|
||||
if (total > cap) {
|
||||
callback(new Error(`ZIP entry decompressed past declared size: ${entry.name}`));
|
||||
return;
|
||||
}
|
||||
for (const byte of chunk) {
|
||||
crcValue = crcTable[(crcValue ^ byte) & 0xff] ^ (crcValue >>> 8);
|
||||
}
|
||||
callback(null, chunk);
|
||||
},
|
||||
flush(callback) {
|
||||
if (total !== entry.uncompressedSize) {
|
||||
callback(new Error(`ZIP entry size check failed: ${entry.name}`));
|
||||
return;
|
||||
}
|
||||
const actual = (crcValue ^ 0xffffffff) >>> 0;
|
||||
if (actual !== entry.crc32) {
|
||||
callback(new Error(`ZIP entry checksum failed: ${entry.name}`));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function streamZipEntry(zipPath, entry, writable, transform = null) {
|
||||
if (!entry?.supported) {
|
||||
throw new Error(`ZIP entry cannot be served: ${entry?.name ?? "unknown"}`);
|
||||
}
|
||||
if (entry.uncompressedSize > MAX_STREAMED_ENTRY_BYTES) {
|
||||
throw new Error("ZIP entry exceeds the 512 MiB streaming safety limit.");
|
||||
}
|
||||
if (entry.compressedSize === 0) {
|
||||
writable.end();
|
||||
return;
|
||||
}
|
||||
const { start, endExclusive } = await entryDataRange(zipPath, entry);
|
||||
const source = createReadStream(zipPath, { start, end: endExclusive - 1 });
|
||||
const streams = [source];
|
||||
if (entry.method !== 0) streams.push(createInflateRaw());
|
||||
streams.push(createVerifyTransform(entry));
|
||||
if (transform) streams.push(transform);
|
||||
streams.push(writable);
|
||||
await pipeline(...streams);
|
||||
}
|
||||
|
||||
export async function readEntryPrefix(zipPath, entry, maxBytes) {
|
||||
if (!entry?.supported) return null;
|
||||
if (entry.uncompressedSize === 0) return Buffer.alloc(0);
|
||||
const { start } = await entryDataRange(zipPath, entry);
|
||||
if (entry.method === 0) {
|
||||
const readBytes = Math.min(maxBytes, entry.compressedSize);
|
||||
const handle = await open(zipPath, "r");
|
||||
try {
|
||||
return await readExactly(handle, readBytes, start);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
const handle = await open(zipPath, "r");
|
||||
let compressed;
|
||||
try {
|
||||
compressed = await readExactly(handle, entry.compressedSize, start);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const inflate = createInflateRaw();
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
let done = false;
|
||||
inflate.on("data", (chunk) => {
|
||||
if (done) return;
|
||||
const remaining = maxBytes - total;
|
||||
if (remaining <= 0) {
|
||||
done = true;
|
||||
inflate.destroy();
|
||||
resolve(Buffer.concat(chunks));
|
||||
return;
|
||||
}
|
||||
const slice = remaining < chunk.length ? chunk.subarray(0, remaining) : chunk;
|
||||
chunks.push(slice);
|
||||
total += slice.length;
|
||||
if (total >= maxBytes) {
|
||||
done = true;
|
||||
inflate.destroy();
|
||||
resolve(Buffer.concat(chunks));
|
||||
}
|
||||
});
|
||||
inflate.once("end", () => {
|
||||
if (!done) {
|
||||
done = true;
|
||||
resolve(Buffer.concat(chunks));
|
||||
}
|
||||
});
|
||||
inflate.once("error", (err) => {
|
||||
if (!done) {
|
||||
done = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
inflate.end(compressed);
|
||||
});
|
||||
}
|
||||