Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/terminal/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
/>
<meta name="color-scheme" content="dark light" />
<meta name="robots" content="noindex, nofollow" />
<meta name="description" content="A local browser-based terminal hub." />
Expand All @@ -19,8 +22,12 @@
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cstyle%3Esvg{color:%23101010}@media(prefers-color-scheme:dark){svg{color:%23ededed}}%3C/style%3E%3Crect x='2' y='4' width='20' height='16' rx='2'/%3E%3Cpath d='m6 8 4 4-4 4M12 16h6'/%3E%3C/svg%3E"
/>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<meta name="application-name" content="localterm" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="localterm" />
<title>localterm</title>
</head>
<body>
Expand Down
26 changes: 14 additions & 12 deletions apps/terminal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,25 @@
"type": "module",
"scripts": {
"dev": "vp dev",
"build": "tsc -b && vp build",
"build": "tsc -b && vp build && node scripts/generate-sw.mjs",
"generate:icons": "node scripts/generate-icons.mjs",
"generate:sw": "node scripts/generate-sw.mjs",
"preview": "vp preview",
"test": "vp test --run",
"test": "node ../../scripts/verify-webgl-fork-patch.mjs && node ../../scripts/verify-pwa-build.mjs && vp test --run",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@base-ui/react": "^1.4.1",
"@fontsource/geist-mono": "^5.2.7",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-image": "^0.9.0",
"@xterm/addon-progress": "^0.2.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-unicode-graphemes": "^0.4.0",
"@xterm/addon-web-fonts": "0.1.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"@xterm/addon-clipboard": "0.3.0-beta.287",
"@xterm/addon-fit": "0.12.0-beta.287",
"@xterm/addon-image": "0.10.0-beta.287",
"@xterm/addon-progress": "0.3.0-beta.287",
"@xterm/addon-search": "0.17.0-beta.287",
"@xterm/addon-unicode-graphemes": "0.5.0-beta.287",
"@xterm/addon-web-links": "0.13.0-beta.287",
"@xterm/addon-webgl": "0.20.0-beta.286",
"@xterm/xterm": "6.1.0-beta.287",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"localterm-server": "workspace:*",
Expand All @@ -42,6 +43,7 @@
"@vitejs/plugin-react": "^5.0.0",
"jsdom": "^29.0.2",
"react-grab": "^0.1.32",
"sharp": "^0.35.3",
"tailwindcss": "^4.1.14",
"typescript": "^5.9.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.12",
Expand Down
Binary file added apps/terminal/public/icons/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/terminal/public/icons/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions apps/terminal/public/icons/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 18 additions & 1 deletion apps/terminal/public/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"id": "/",
"name": "localterm",
"short_name": "localterm",
"description": "Local browser-based terminal hub",
Expand All @@ -9,12 +10,28 @@
"background_color": "#101010",
"theme_color": "#101010",
"orientation": "any",
"lang": "en",
"dir": "auto",
"categories": ["developer", "productivity", "utilities"],
"launch_handler": { "client_mode": "navigate-existing" },
"icons": [
{
"src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ededed' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='4' width='20' height='16' rx='2'/%3E%3Cpath d='m6 8 4 4-4 4M12 16h6'/%3E%3C/svg%3E",
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
32 changes: 32 additions & 0 deletions apps/terminal/scripts/generate-icons.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";

const RASTER_DENSITY_DPI = 2048;
const ICON_SMALL_SIZE_PX = 192;
const ICON_LARGE_SIZE_PX = 512;
const BACKGROUND_HEX = "#f4f4f5";
const ICON_TARGETS = [
{ name: "icon-192.png", size: ICON_SMALL_SIZE_PX },
{ name: "icon-512.png", size: ICON_LARGE_SIZE_PX },
];

const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
const iconsDirectory = path.resolve(moduleDirectory, "..", "public", "icons");
const source = readFileSync(path.join(iconsDirectory, "icon.svg"));

const renderIcon = (size) =>
sharp(source, { density: RASTER_DENSITY_DPI })
.resize(size, size)
.flatten({ background: BACKGROUND_HEX })
.png();

const main = async () => {
for (const { name, size } of ICON_TARGETS) {
await renderIcon(size).toFile(path.join(iconsDirectory, name));
console.log(`generated icons/${name} (${size}x${size})`);
}
};

await main();
93 changes: 93 additions & 0 deletions apps/terminal/scripts/generate-sw.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const SHELL_VERSION_LENGTH = 12;
const FONT_EXTENSIONS = [".woff", ".woff2", ".ttf", ".otf"];
const ICON_EXTENSIONS = [".svg", ".png", ".ico"];
const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
const distDirectory = path.resolve(moduleDirectory, "..", "dist");
const templatePath = path.resolve(moduleDirectory, "sw-template.js");
const outputPath = path.join(distDirectory, "sw.js");

const toUrl = (distPath) => {
const relative = path.relative(distDirectory, distPath).split(path.sep).join("/");
return relative === "index.html" ? "/" : `/${relative}`;
};

const extractShellUrls = () => {
const html = readFileSync(path.join(distDirectory, "index.html"), "utf8");
const urls = new Set();
const attributePattern = /(?:src|href)\s*=\s*"([^"]+)"/g;
let match = attributePattern.exec(html);
while (match !== null) {
const value = match[1];
if (value.startsWith("/")) urls.add(value);
match = attributePattern.exec(html);
}
return urls;
};

const collectAssetUrlsByExtension = (extensions) => {
const urls = new Set();
const walk = (directory) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else if (extensions.includes(path.extname(entry.name).toLowerCase())) {
urls.add(toUrl(fullPath));
}
}
};
walk(distDirectory);
return urls;
};

const isPrecacheable = (url) => !url.endsWith(".map") && url !== "/sw.js";

const contentHashOf = (url) => {
const distPath =
url === "/" ? path.join(distDirectory, "index.html") : path.join(distDirectory, url);
if (!existsSync(distPath)) return null;
return createHash("sha1").update(readFileSync(distPath)).digest("hex");
};

const buildVersion = (urls) => {
const hasher = createHash("sha1");
for (const url of urls) {
const hash = contentHashOf(url);
if (hash) hasher.update(`${url}:${hash}\n`);
}
return hasher.digest("hex").slice(0, SHELL_VERSION_LENGTH);
};

const main = () => {
if (!existsSync(distDirectory)) {
throw new Error(`dist not found at ${distDirectory}`);
}
if (!existsSync(templatePath)) {
throw new Error(`service worker template not found at ${templatePath}`);
}

const shellUrls = extractShellUrls();
const fontUrls = collectAssetUrlsByExtension(FONT_EXTENSIONS);
const iconUrls = collectAssetUrlsByExtension(ICON_EXTENSIONS);
const precacheUrls = ["/", ...shellUrls, ...fontUrls, ...iconUrls]
.filter(isPrecacheable)
.toSorted();
const uniquePrecacheUrls = [...new Set(precacheUrls)];
const version = buildVersion(uniquePrecacheUrls);
const template = readFileSync(templatePath, "utf8");
const serviceWorker = template
.replaceAll('"__SW_VERSION__"', JSON.stringify(version))
.replace("__PRECACHE_URLS_JSON__", JSON.stringify(JSON.stringify(uniquePrecacheUrls)));

writeFileSync(outputPath, serviceWorker);
console.log(
`generated ${path.relative(moduleDirectory, outputPath)} (v${version}, ${uniquePrecacheUrls.length} precache entries)`,
);
};

main();
79 changes: 79 additions & 0 deletions apps/terminal/scripts/sw-template.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
const SHELL_VERSION = "__SW_VERSION__";
const PRECACHE_URLS = JSON.parse(__PRECACHE_URLS_JSON__);
const CACHE_PREFIX = "localterm-shell-v";
const PRECACHE = `${CACHE_PREFIX}${SHELL_VERSION}`;
const SHELL_URL = "/";

const isShellAsset = (request, url) =>
request.method === "GET" &&
url.origin === self.location.origin &&
!url.pathname.startsWith("/api/") &&
url.pathname !== "/ws";

const fromCacheOrNetwork = async (request) => {
const cache = await caches.open(PRECACHE);
const cached = await cache.match(request);
if (cached) return cached;
const fresh = await fetch(request);
if (fresh.ok) await cache.put(request, fresh.clone());
return fresh;
};

self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(PRECACHE);
await Promise.all(
PRECACHE_URLS.map(async (url) => {
try {
await cache.add(url);
} catch {
// A deploy can replace hashed assets while an older worker is installing.
}
}),
);
await self.skipWaiting();
})(),
);
});

self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys
.filter((key) => key.startsWith(CACHE_PREFIX) && key !== PRECACHE)
.map((key) => caches.delete(key)),
);
await self.clients.claim();
})(),
);
});

self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
if (!isShellAsset(request, url)) return;

if (request.mode === "navigate") {
event.respondWith(
(async () => {
try {
const fresh = await fetch(request);
if (fresh.ok) {
const cache = await caches.open(PRECACHE);
await cache.put(SHELL_URL, fresh.clone());
}
return fresh;
} catch {
const cache = await caches.open(PRECACHE);
return (await cache.match(SHELL_URL)) || (await cache.match(request)) || Response.error();
}
})(),
);
return;
}

event.respondWith(fromCacheOrNetwork(request).catch(() => Response.error()));
});
Loading
Loading