1#!/usr/bin/env node
2
3// Android masks a 72dp safe area inside 108dp; full-bleed backgrounds and a separate wordmark avoid double silhouettes/cropping.
4
5import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import * as Console from "effect/Console";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Path from "effect/Path";
11import * as Schema from "effect/Schema";
12import sharp from "sharp";
13
14type IconVariant = "dev" | "nightly" | "prod";
15
16const ADAPTIVE_CANVAS = 432;
17const SPLASH_CANVAS = 1152;
18const TEXT = { x: 15.53, y: 37, width: 94.5, height: 57 };
19const WORDMARK_FRACTION = 0.48;
20const SVG_DENSITY = 300;
21const OUTPUT_DIRECTORY = "apps/mobile/assets";
22const PRODUCTION_BACKGROUND_COLOR = "#000000";
23
24export class AndroidIconRenderError extends Schema.TaggedError<AndroidIconRenderError>()(
25 "AndroidIconRenderError",
26 { layer: Schema.String, cause: Schema.Defect() },
27) {}
28
29const wordmarkTransform = (size: number) => {
30 const scale = (size * WORDMARK_FRACTION) / TEXT.width;
31 const tx = (size - TEXT.width * scale) / 2 - TEXT.x * scale;
32 const ty = (size - TEXT.height * scale) / 2 - TEXT.y * scale;
33 return `translate(${tx.toFixed(3)} ${ty.toFixed(3)}) scale(${scale.toFixed(4)})`;
34};
35
36const canvasSvg = (size: number, inner: string) =>
37 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" fill="none">${inner}</svg>`;
38
39// Remove the iOS 10pt corner clip; Android's own mask needs full bleed.
40const fullBleed = (svg: string) =>
41 svg.replace(/<rect width="128" height="128" rx="10"\/>/, '<rect width="128" height="128"/>');
42
43const rasterize = (layer: string, svg: string, width: number, height = width) =>
44 Effect.tryPromise({
45 try: () =>
46 sharp(Buffer.from(svg), { density: SVG_DENSITY }).resize(width, height).png().toBuffer(),
47 catch: (cause) => new AndroidIconRenderError({ layer, cause }),
48 });
49
50const composite = (
51 layer: string,
52 base: Buffer,
53 overlays: ReadonlyArray<{ input: Buffer; left?: number; top?: number }>,
54) =>
55 Effect.tryPromise({
56 try: () =>
57 sharp(base)
58 .composite([...overlays])
59 .png()
60 .toBuffer(),
61 catch: (cause) => new AndroidIconRenderError({ layer, cause }),
62 });
63
64const solidCanvas = (layer: string, size: number, background: string) =>
65 Effect.tryPromise({
66 try: () =>
67 sharp({ create: { width: size, height: size, channels: 4, background } })
68 .png()
69 .toBuffer(),
70 catch: (cause) => new AndroidIconRenderError({ layer, cause }),
71 });
72
73const readLayerSource = Effect.fn("androidIcons.readLayerSource")(function* (
74 repositoryRoot: string,
75 variant: IconVariant,
76 file: string,
77) {
78 const fs = yield* FileSystem.FileSystem;
79 const path = yield* Path.Path;
80 return yield* fs.readFileString(
81 path.join(repositoryRoot, "assets", variant, "app-icon.icon", "Assets", file),
82 );
83});
84
85const renderForeground = Effect.fn("androidIcons.renderForeground")(function* (
86 repositoryRoot: string,
87 size: number,
88) {
89 const text = yield* readLayerSource(repositoryRoot, "prod", "text.svg");
90 const paths = text.match(/<path[^>]*\/>/g) ?? [];
91 return yield* rasterize(
92 "foreground",
93 canvasSvg(size, `<g transform="${wordmarkTransform(size)}">${paths.join("")}</g>`),
94 size,
95 );
96});
97
98const renderDevelopmentBackground = Effect.fn("androidIcons.renderDevelopmentBackground")(
99 function* (repositoryRoot: string, size: number) {
100 const annotations = yield* readLayerSource(repositoryRoot, "dev", "annotations.svg");
101 const defs = annotations.match(/<defs>[\s\S]*?<\/defs>/)?.[0] ?? "";
102 const body = annotations.replace(/^[\s\S]*?<\/defs>/, "").replace(/<\/svg>\s*$/, "");
103 const paper = yield* readLayerSource(repositoryRoot, "dev", "background.svg");
104 const background = yield* rasterize("dev-background", fullBleed(paper), size);
105 const overlay = yield* rasterize(
106 "dev-annotations",
107 canvasSvg(size, `${defs}<g transform="${wordmarkTransform(size)}">${body}</g>`),
108 size,
109 );
110 return yield* composite("dev-background", background, [{ input: overlay }]);
111 },
112);
113
114const renderNightlyBackground = Effect.fn("androidIcons.renderNightlyBackground")(function* (
115 repositoryRoot: string,
116 size: number,
117) {
118 // Orbital Sunrise is a single full-bleed layer (assets/nightly/app-icon.icon/icon.json).
119 const sky = yield* readLayerSource(repositoryRoot, "nightly", "background.svg");
120 return yield* rasterize("nightly-background", fullBleed(sky), size);
121});
122
123const renderBackground = Effect.fn("androidIcons.renderBackground")(function* (
124 repositoryRoot: string,
125 variant: IconVariant,
126 size: number,
127) {
128 switch (variant) {
129 case "dev":
130 return yield* renderDevelopmentBackground(repositoryRoot, size);
131 case "nightly":
132 return yield* renderNightlyBackground(repositoryRoot, size);
133 case "prod":
134 return yield* solidCanvas("prod-background", size, PRODUCTION_BACKGROUND_COLOR);
135 }
136});
137
138const renderSplashIcon = Effect.fn("androidIcons.renderSplashIcon")(function* (
139 repositoryRoot: string,
140 variant: IconVariant,
141) {
142 const background = yield* renderBackground(repositoryRoot, variant, SPLASH_CANVAS);
143 const foreground = yield* renderForeground(repositoryRoot, SPLASH_CANVAS);
144 return yield* composite(`${variant}-splash`, background, [{ input: foreground }]);
145});
146
147const exportAndroidIcons = Effect.gen(function* () {
148 const fs = yield* FileSystem.FileSystem;
149 const path = yield* Path.Path;
150 const repositoryRoot = path.resolve(import.meta.dirname, "..");
151 const outputs = [
152 ["android-icon-foreground.png", yield* renderForeground(repositoryRoot, ADAPTIVE_CANVAS)],
153 [
154 "android-icon-background-dev.png",
155 yield* renderDevelopmentBackground(repositoryRoot, ADAPTIVE_CANVAS),
156 ],
157 [
158 "android-icon-background-nightly.png",
159 yield* renderNightlyBackground(repositoryRoot, ADAPTIVE_CANVAS),
160 ],
161 ["android-splash-icon-dev.png", yield* renderSplashIcon(repositoryRoot, "dev")],
162 ["android-splash-icon-nightly.png", yield* renderSplashIcon(repositoryRoot, "nightly")],
163 ["android-splash-icon-prod.png", yield* renderSplashIcon(repositoryRoot, "prod")],
164 ] as const;
165 for (const [name, contents] of outputs) {
166 yield* fs.writeFile(path.join(repositoryRoot, OUTPUT_DIRECTORY, name), contents);
167 yield* Console.log(`wrote ${OUTPUT_DIRECTORY}/${name}`);
168 }
169});
170
171if (import.meta.main) {
172 exportAndroidIcons.pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain);
173}