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
156 changes: 96 additions & 60 deletions branding/assets/build-icons.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -138,75 +138,111 @@ async function buildTrayVariant(name, svg, dir, accent) {
console.log(` ✓ ${name}: 16/20/24/32px .ico`);
}

// One PWA icon set per release channel. Stable and nightly are installed side
// by side from separate origins (app.poracode.com / app-nightly.poracode.com),
// so nightly needs its own art or the two are indistinguishable on a home
// screen.
const PWA_VARIANTS = [
{ suffix: "", svg: "poracode-icon.svg" },
{ suffix: "-nightly", svg: "poracode-icon-nightly.svg" },
];

// Maskable and apple-touch icons must be opaque corner to corner: the platform
// applies its own mask (circle, squircle, rounded rect) and any transparency
// around our squircle shows through as a notch. Stretch the tile shape to a
// full-bleed rect and keep every fill — including the nightly sheen overlay —
// so the backdrop is the tile art itself rather than an approximated flat
// colour composited behind it, which leaves a visible seam on a gradient.
// The glyph is already inset well inside the 80% safe zone at this viewBox.
function fullBleedSvg(source) {
return source.replaceAll(
/<use xlink:href="#tile" fill="([^"]+)"\s*\/>/g,
'<rect width="1024" height="1024" fill="$1"/>',
);
}

async function buildPwaVariant(dir, { suffix, svg }) {
const iconSvg = `${HERE}${svg}`;
// Plain transparent renders — the tile bg is baked into the SVG.
await writeFile(`${dir}/icon${suffix}-192.png`, await png(iconSvg, 192));
await writeFile(`${dir}/icon${suffix}-512.png`, await png(iconSvg, 512));
const source = await readFile(iconSvg, "utf8");
const bleed = Buffer.from(fullBleedSvg(source));
if (bleed.equals(Buffer.from(source))) {
throw new Error(`${svg}: no #tile <use> to expand for the maskable icon`);
}
await writeFile(`${dir}/icon${suffix}-maskable-512.png`, await png(bleed, 512));
await writeFile(`${dir}/apple-touch-icon${suffix}.png`, await png(bleed, 180));
console.log(` ✓ icon${suffix}: 192 + 512 + maskable-512 + apple-touch`);
}

async function buildTrayMacTemplate(svg, dir) {
await mkdir(dir, { recursive: true });
await writeFile(`${dir}/tray-icon-mac.png`, await trayMacTemplatePng(svg, 22));
await writeFile(`${dir}/tray-icon-mac@2x.png`, await trayMacTemplatePng(svg, 44));
console.log(" ✓ tray-icon-mac: 22px + @2x template PNG");
}

// Optional section filter (`node build-icons.mjs pwa`). The `build` section
// shells out to macOS `iconutil` for .icns, so contributors on other hosts can
// still regenerate the `website` and `pwa` sections on their own.
const SECTIONS = ["build", "website", "pwa"];
const only = process.argv[2];
if (only && !SECTIONS.includes(only)) {
console.error(`unknown section "${only}"; expected one of ${SECTIONS.join(", ")}`);
process.exit(1);
}
const wants = (section) => !only || only === section;

async function main() {
await rm(OUT, { recursive: true, force: true });
console.log("build/ (app icons):");
await buildVariant("icon", `${HERE}poracode-icon.svg`, `${OUT}/build`);
await buildVariant("icon-nightly", `${HERE}poracode-icon-nightly.svg`, `${OUT}/build`);
await buildTrayVariant("tray-icon", `${HERE}poracode-glyph.svg`, `${OUT}/build`, "#8B7BFF");
await buildTrayVariant(
"tray-icon-nightly",
`${HERE}poracode-glyph.svg`,
`${OUT}/build`,
"#5EE6E0",
);
await buildTrayMacTemplate(`${HERE}poracode-glyph.svg`, `${OUT}/build`);

console.log("website/public (favicons):");
const web = `${OUT}/website`;
await mkdir(web, { recursive: true });
const svg = `${HERE}poracode-icon.svg`;
const map = {
"favicon-48x48.png": 48,
"favicon-96x96.png": 96,
"icon-192.png": 192,
"icon-512.png": 512,
"icon.png": 512,
};
for (const [file, s] of Object.entries(map)) await writeFile(`${web}/${file}`, await png(svg, s));
await writeFile(
`${web}/favicon.ico`,
buildIco(
await Promise.all([48, 32, 16].map(async (s) => ({ size: s, buf: await png(svg, s) }))),
),
);
console.log(" ✓ favicons + favicon.ico");
for (const section of SECTIONS) {
if (wants(section)) await rm(`${OUT}/${section}`, { recursive: true, force: true });
}

console.log("pwa/ (mobile PWA icons):");
const pwa = `${OUT}/pwa`;
await mkdir(pwa, { recursive: true });
const iconSvg = `${HERE}poracode-icon.svg`;
// Plain transparent renders — the tile bg is baked into the SVG.
await writeFile(`${pwa}/icon-192.png`, await png(iconSvg, 192));
await writeFile(`${pwa}/icon-512.png`, await png(iconSvg, 512));
// Maskable: full-bleed opaque tile with the glyph inside the ~80% safe zone.
await writeFile(
`${pwa}/icon-maskable-512.png`,
await sharp({
create: { width: 512, height: 512, channels: 4, background: "#0e0e14" },
})
.composite([{ input: await png(iconSvg, 440), gravity: "centre" }])
.png()
.toBuffer(),
);
// Apple touch: iOS applies its own corner mask, so corners must be opaque.
await writeFile(
`${pwa}/apple-touch-icon.png`,
await sharp({
create: { width: 180, height: 180, channels: 4, background: "#0e0e14" },
})
.composite([{ input: await png(iconSvg, 150), gravity: "centre" }])
.png()
.toBuffer(),
);
console.log(" ✓ icon-192 + icon-512 + icon-maskable-512 + apple-touch-icon");
if (wants("build")) {
console.log("build/ (app icons):");
await buildVariant("icon", `${HERE}poracode-icon.svg`, `${OUT}/build`);
await buildVariant("icon-nightly", `${HERE}poracode-icon-nightly.svg`, `${OUT}/build`);
await buildTrayVariant("tray-icon", `${HERE}poracode-glyph.svg`, `${OUT}/build`, "#8B7BFF");
await buildTrayVariant(
"tray-icon-nightly",
`${HERE}poracode-glyph.svg`,
`${OUT}/build`,
"#5EE6E0",
);
await buildTrayMacTemplate(`${HERE}poracode-glyph.svg`, `${OUT}/build`);
}

if (wants("website")) {
console.log("website/public (favicons):");
const web = `${OUT}/website`;
await mkdir(web, { recursive: true });
const svg = `${HERE}poracode-icon.svg`;
const map = {
"favicon-48x48.png": 48,
"favicon-96x96.png": 96,
"icon-192.png": 192,
"icon-512.png": 512,
"icon.png": 512,
};
for (const [file, s] of Object.entries(map)) {
await writeFile(`${web}/${file}`, await png(svg, s));
}
await writeFile(
`${web}/favicon.ico`,
buildIco(
await Promise.all([48, 32, 16].map(async (s) => ({ size: s, buf: await png(svg, s) }))),
),
);
console.log(" ✓ favicons + favicon.ico");
}

if (wants("pwa")) {
console.log("pwa/ (mobile PWA icons):");
const pwa = `${OUT}/pwa`;
await mkdir(pwa, { recursive: true });
for (const variant of PWA_VARIANTS) await buildPwaVariant(pwa, variant);
}

console.log(`\nDone → ${OUT}`);
}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified branding/assets/out/pwa/apple-touch-icon.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 modified branding/assets/out/pwa/icon-maskable-512.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 branding/assets/out/pwa/icon-nightly-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 branding/assets/out/pwa/icon-nightly-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 19 additions & 0 deletions public/app-icon-nightly.svg
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 public/icons/apple-touch-icon-nightly.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 modified public/icons/apple-touch-icon.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 modified public/icons/icon-maskable-512.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 public/icons/icon-nightly-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 public/icons/icon-nightly-512.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 public/icons/icon-nightly-maskable-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 5 additions & 2 deletions public/service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ const NAVIGATION_FALLBACK_DELAY_MS = 500;
const APP_BASE_URL = new URL("./", self.location.href);
const shellUrl = (path) => new URL(path, APP_BASE_URL).pathname;
const SHELL_URLS = ["./", "app", "manifest.webmanifest", "app-icon.svg"].map(shellUrl);
// Substituted per channel by scripts/finalize-mobile-build.mjs so a nightly
// install's notifications carry the nightly art, not the stable icon.
const NOTIFICATION_ICON_URL = shellUrl("__PORACODE_NOTIFICATION_ICON__");

function shellAssetUrls(html) {
const urls = new Set();
Expand Down Expand Up @@ -91,8 +94,8 @@ self.addEventListener("push", (event) => {
if (windows.some((client) => client.visibilityState === "visible")) return;
return self.registration.showNotification(payload.title, {
body: payload.body,
icon: new URL("icons/icon-192.png", APP_BASE_URL).pathname,
badge: new URL("icons/icon-192.png", APP_BASE_URL).pathname,
icon: NOTIFICATION_ICON_URL,
badge: NOTIFICATION_ICON_URL,
tag: `poracode-thread-${payload.threadId}`,
data: { url: payload.url },
});
Expand Down
67 changes: 60 additions & 7 deletions scripts/finalize-mobile-build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,49 @@ if (requireIosLinks && !appleTeamId) {
process.exit(1);
}

copyFileSync(source, target);
// public/ ships both channels' icon art. Nightly swaps every stable reference
// over to its own set so an installed nightly PWA is distinguishable from
// stable on a home screen or taskbar — the two only differ by origin
// otherwise. Mapped explicitly (rather than by pattern) so a newly added icon
// fails the build instead of silently staying on the stable art.
const NIGHTLY_ICON_SOURCES = {
"icons/icon-192.png": "icons/icon-nightly-192.png",
"icons/icon-512.png": "icons/icon-nightly-512.png",
"icons/icon-maskable-512.png": "icons/icon-nightly-maskable-512.png",
"icons/apple-touch-icon.png": "icons/apple-touch-icon-nightly.png",
"app-icon.svg": "app-icon-nightly.svg",
};

// Rewrites a manifest/HTML icon reference, preserving any "./" or "/" prefix.
function nightlyIconRef(ref) {
const match = /^(\.?\/)?(.*)$/.exec(ref);
const mapped = NIGHTLY_ICON_SOURCES[match[2]];
if (!mapped) {
console.error(`[finalize-mobile-build] no nightly icon mapped for ${ref}`);
process.exit(1);
}
return `${match[1] ?? ""}${mapped}`;
}

const isNightly = mobileChannel === "nightly";
let html = readFileSync(source, "utf8");
if (isNightly) {
// Vite has already resolved %BASE_URL%, so match the bare relative path and
// let whatever prefix precedes it ("./" or "/") stand.
for (const [stable, nightly] of Object.entries(NIGHTLY_ICON_SOURCES)) {
html = html.replaceAll(stable, nightly);
}
const missed = Object.keys(NIGHTLY_ICON_SOURCES).filter((stable) => html.includes(stable));
if (missed.length > 0) {
console.error(`[finalize-mobile-build] stable icon refs survived: ${missed.join(", ")}`);
process.exit(1);
}
html = html
.replaceAll("<title>Poracode</title>", "<title>Poracode Nightly</title>")
.replaceAll('web-app-title" content="Poracode"', 'web-app-title" content="Poracode Nightly"');
writeFileSync(source, html, "utf8");
}
writeFileSync(target, html, "utf8");

// Hosted stable and nightly builds live on separate subdomains and both own
// their origin root. The origin itself separates their PWA identities.
Expand All @@ -55,21 +97,32 @@ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
manifest.id = "/";
manifest.scope = "/";
manifest.start_url = "/threads";
if (mobileChannel === "nightly") {
if (isNightly) {
manifest.name = `${manifest.name} Nightly`;
manifest.short_name = `${manifest.short_name} Nightly`;
manifest.icons = manifest.icons.map((icon) => ({ ...icon, src: nightlyIconRef(icon.src) }));
}
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
console.log(`[finalize-mobile-build] prepared the ${mobileChannel} root-scoped manifest`);

const serviceWorker = readFileSync(serviceWorkerPath, "utf8");
const buildVersion = createHash("sha256").update(readFileSync(source)).digest("hex").slice(0, 12);
const versionToken = "__PORACODE_BUILD_VERSION__";
if (!serviceWorker.includes(versionToken)) {
console.error(`[finalize-mobile-build] missing build-version token in ${serviceWorkerPath}`);
process.exit(1);
const notificationIcon = isNightly
? NIGHTLY_ICON_SOURCES["icons/icon-192.png"]
: "icons/icon-192.png";
const tokens = {
__PORACODE_BUILD_VERSION__: buildVersion,
__PORACODE_NOTIFICATION_ICON__: notificationIcon,
};
let resolvedWorker = serviceWorker;
for (const [token, value] of Object.entries(tokens)) {
if (!resolvedWorker.includes(token)) {
console.error(`[finalize-mobile-build] missing ${token} in ${serviceWorkerPath}`);
process.exit(1);
}
resolvedWorker = resolvedWorker.replaceAll(token, value);
}
writeFileSync(serviceWorkerPath, serviceWorker.replaceAll(versionToken, buildVersion), "utf8");
writeFileSync(serviceWorkerPath, resolvedWorker, "utf8");
mkdirSync(sshRuntimeTargetDir, { recursive: true });
copyFileSync(
join(sshRuntimeSourceDir, "manifest.json"),
Expand Down
Loading