refactor: dedupe diff logic, simplify runner, decompose canvas

- extract 4 diff fns into generic diffEntries + diffEntriesCode
- drop 120s polling loop; await gzip promise directly
- filter-before-map in webhook, inline color cast helpers
- decompose drawSections into measure/render helpers
- validate colors VM eval result shape
- chain git commits via lastCommit promise
- drop unused Differs fields, trim types
This commit is contained in:
lvwmwm 2026-09-04 13:20:42 -03:00
parent 0f7170e1c7
commit dc2b7299ff
10 changed files with 390 additions and 399 deletions

View file

@ -1,8 +1,8 @@
import { readdir } from "node:fs/promises"; import { readdir } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { createCanvas, GlobalFonts } from "@napi-rs/canvas"; import { createCanvas, GlobalFonts, type SKRSContext2D } from "@napi-rs/canvas";
import { file } from "bun"; import { file } from "bun";
import type { Row, Section } from "./factory"; import type { Item, Row, Section } from "./factory";
import { measureText, transformSvg, wrapText } from "./utils"; import { measureText, transformSvg, wrapText } from "./utils";
async function bulkRegister(family: string, path: string) { async function bulkRegister(family: string, path: string) {
@ -11,6 +11,37 @@ async function bulkRegister(family: string, path: string) {
await bulkRegister("GG Sans", join(import.meta.dir, "fonts/ggsans")); await bulkRegister("GG Sans", join(import.meta.dir, "fonts/ggsans"));
await bulkRegister("GG Mono", join(import.meta.dir, "fonts/ggmono")); await bulkRegister("GG Mono", join(import.meta.dir, "fonts/ggmono"));
const layout = {
background: { padding: 24, gap: 18 },
section: { gap: 8 },
card: { radius: 24, padding: 12, gapX: 16, gapY: 8 },
blob: { radius: 12 },
color: { radius: 24 },
};
const textSizes = { header: 24, title: 18, blob: 24 };
const colors = {
background: "#1c1d23",
section: "#26272f",
blob: "#6c6f7c1f",
textNormal: "#c7c8ce",
textMuted: "#818491",
watermark: "#2f3035",
};
const itemSize = 128;
const blobSize = itemSize + layout.card.gapY + textSizes.title;
const watermark = await transformSvg(await file(join(import.meta.dir, "placeholders/watermark.svg")).text(), {
color: colors.watermark,
height: layout.background.padding,
});
const mctx = createCanvas(1, 1).getContext("2d");
const headerFont = `600 ${textSizes.header}px GG Sans`;
const titleFont = `400 ${textSizes.title}px GG Mono`;
function calcColumnWidth(column: { textWidth: number; itemWidth: number }[]) { function calcColumnWidth(column: { textWidth: number; itemWidth: number }[]) {
return Math.max(0, ...column.map(({ textWidth, itemWidth }) => Math.max(0, textWidth, itemWidth))); return Math.max(0, ...column.map(({ textWidth, itemWidth }) => Math.max(0, textWidth, itemWidth)));
} }
@ -27,81 +58,37 @@ function calcSectionHeight(columns: Row[][]) {
return Math.max(0, ...columns.map((column) => calcColumnHeight(column))); return Math.max(0, ...columns.map((column) => calcColumnHeight(column)));
} }
const layout = { function measureItemWidth(item: Item) {
background: { if (item.type === "blob") return blobSize;
padding: 24, if (item.type === "image") return (item.image.width / item.image.height) * itemSize;
gap: 18, return itemSize;
}, }
section: {
gap: 8,
},
card: {
radius: 24,
padding: 12,
gapX: 16,
gapY: 8,
},
blob: {
radius: 24 - 12,
},
color: {
radius: 24,
},
};
const textSizes = { function measureRow(row: Row) {
header: 24, return {
title: 18, title: row.title,
blob: 24, subtitle: row.subtitle,
}; textWidth: Math.max(
0,
const colors = { row.title ? measureText(mctx, titleFont, row.title).width : 0,
background: "#1c1d23", // BACKGROUND_BASE_LOWEST row.subtitle ? measureText(mctx, titleFont, row.subtitle).width : 0,
section: "#26272f", // CARD_BACKGROUND_DEFAULT ),
blob: "#6c6f7c1f", // BACKGROUND_MOD_MUTED item: row.item,
textNormal: "#c7c8ce", // TEXT_NORMAL itemWidth: measureItemWidth(row.item),
textMuted: "#818491", // TEXT_MUTED };
watermark: "#2f3035", // TEXT_OVERLAY_DARK }
};
const itemSize = 128;
const blobSize = itemSize + layout.card.gapY + textSizes.title;
const watermark = await transformSvg(await file(join(import.meta.dir, "placeholders/watermark.svg")).text(), {
color: colors.watermark,
height: layout.background.padding,
});
const mctx = createCanvas(1, 1).getContext("2d");
export function drawSections(sections: Section[]) {
const headerFont = `600 ${textSizes.header}px GG Sans`;
const titleFont = `400 ${textSizes.title}px GG Mono`;
function measureSections(sections: Section[]) {
const sects = sections.map(({ header, columns }) => ({ const sects = sections.map(({ header, columns }) => ({
headerWidth: measureText(mctx, headerFont, header).width, headerWidth: measureText(mctx, headerFont, header).width,
header, header,
columns: columns.map((column) => columns: columns.map((column) => column.map(measureRow)),
column.map(({ title, subtitle, item }) => ({
title,
subtitle,
textWidth: Math.max(
0,
title ? measureText(mctx, titleFont, title).width : 0,
subtitle ? measureText(mctx, titleFont, subtitle).width : 0,
),
item,
itemWidth:
item.type === "blob"
? blobSize
: item.type === "image"
? (item.image.width / item.image.height) * itemSize
: itemSize,
})),
),
})); }));
const columnWidths = Array.from({ length: Math.max(0, ...sects.map(({ columns }) => columns.length)) }, (_, i) => const columnWidths = Array.from({ length: Math.max(0, ...sects.map(({ columns }) => columns.length)) }, (_, i) =>
Math.max(0, ...sects.map(({ columns }) => (columns[i] ? calcColumnWidth(columns[i]) : 0))), Math.max(0, ...sects.map(({ columns }) => (columns[i] ? calcColumnWidth(columns[i]) : 0))),
); );
const sectionWidth = const sectionWidth =
columnWidths.reduce((a, b) => a + b, 0) + layout.card.gapX * (columnWidths.length - 1) + layout.card.padding * 2; columnWidths.reduce((a, b) => a + b, 0) + layout.card.gapX * (columnWidths.length - 1) + layout.card.padding * 2;
@ -118,6 +105,56 @@ export function drawSections(sections: Section[]) {
) )
.reduce((a, b) => a + b, 0) - layout.background.gap; .reduce((a, b) => a + b, 0) - layout.background.gap;
return { sects, columnWidths, sectionWidth, width, height };
}
function renderBlob(ctx: SKRSContext2D, blob: string, x: number, y: number, width: number, height: number) {
ctx.fillStyle = colors.blob;
ctx.beginPath();
ctx.roundRect(x, y, width, height, layout.blob.radius);
ctx.fill();
ctx.font = `600 ${textSizes.blob}px GG Sans`;
ctx.fillStyle = colors.textNormal;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const wrapped = wrapText(ctx, blob, width);
const lineHeight = textSizes.blob / 2;
for (let i = 0; i < wrapped.length; i++) {
ctx.fillText(wrapped[i], x + width / 2, y + height / 2 - lineHeight * (wrapped.length - 1) + textSizes.blob * i);
}
ctx.textAlign = "left";
ctx.textBaseline = "top";
}
function renderColor(ctx: SKRSContext2D, colorGrid: string[][], x: number, y: number) {
for (let i = 0; i < colorGrid.length; i++) {
const row = colorGrid[i];
for (let j = 0; j < row.length; j++) {
ctx.save();
ctx.beginPath();
ctx.rect(
Math.floor(x + (itemSize / row.length) * j),
Math.floor(y + (itemSize / colorGrid.length) * i),
Math.ceil(itemSize / row.length),
Math.ceil(itemSize / colorGrid.length),
);
ctx.clip();
ctx.beginPath();
ctx.fillStyle = row[j];
ctx.roundRect(x, y, itemSize, itemSize, layout.color.radius);
ctx.fill();
ctx.restore();
}
}
}
export function drawSections(sections: Section[]) {
const { sects, columnWidths, sectionWidth, width, height } = measureSections(sections);
const canvas = createCanvas(layout.background.padding * 2 + width, layout.background.padding * 2 + height); const canvas = createCanvas(layout.background.padding * 2 + width, layout.background.padding * 2 + height);
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
ctx.textBaseline = "top"; ctx.textBaseline = "top";
@ -125,8 +162,8 @@ export function drawSections(sections: Section[]) {
ctx.fillStyle = colors.background; ctx.fillStyle = colors.background;
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
let x = layout.background.padding, let x = layout.background.padding;
y = layout.background.padding; let y = layout.background.padding;
for (const sect of sects) { for (const sect of sects) {
ctx.fillStyle = colors.textNormal; ctx.fillStyle = colors.textNormal;
ctx.font = headerFont; ctx.font = headerFont;
@ -165,54 +202,12 @@ export function drawSections(sections: Section[]) {
const midX = centerX - row.itemWidth / 2; const midX = centerX - row.itemWidth / 2;
if (row.item.type === "blob") { if (row.item.type === "blob") {
const blobHeight = sectionHeight - wy + y; renderBlob(ctx, row.item.blob, wx, wy, columnWidth, sectionHeight - wy + y);
ctx.fillStyle = colors.blob;
ctx.beginPath();
ctx.roundRect(wx, wy, columnWidth, blobHeight, layout.blob.radius);
ctx.fill();
ctx.font = `600 ${textSizes.blob}px GG Sans`;
ctx.fillStyle = colors.textNormal;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const wrapped = wrapText(ctx, row.item.blob, columnWidth);
const height = textSizes.blob / 2;
for (let i = 0; i < wrapped.length; i++)
ctx.fillText(
wrapped[i],
wx + columnWidth / 2,
wy + blobHeight / 2 - height * (wrapped.length - 1) + textSizes.blob * i,
);
ctx.textAlign = "left";
ctx.textBaseline = "top";
} else if (row.item.type === "image") { } else if (row.item.type === "image") {
ctx.drawImage(row.item.image, midX, wy, row.itemWidth, itemSize); ctx.drawImage(row.item.image, midX, wy, row.itemWidth, itemSize);
wy += itemSize + layout.card.gapY; wy += itemSize + layout.card.gapY;
} else if (row.item.type === "color") { } else if (row.item.type === "color") {
for (let i = 0; i < row.item.colors.length; i++) { renderColor(ctx, row.item.colors, midX, wy);
const colors = row.item.colors[i];
for (let j = 0; j < colors.length; j++) {
const color = colors[j];
ctx.save();
ctx.beginPath();
ctx.rect(
Math.floor(midX + (itemSize / colors.length) * j),
Math.floor(wy + (itemSize / row.item.colors.length) * i),
Math.ceil(itemSize / colors.length),
Math.ceil(itemSize / row.item.colors.length),
);
ctx.clip();
ctx.beginPath();
ctx.fillStyle = color;
ctx.roundRect(midX, wy, itemSize, itemSize, layout.color.radius);
ctx.fill();
ctx.restore();
}
}
wy += itemSize + layout.card.gapY; wy += itemSize + layout.card.gapY;
} }
} }
@ -225,7 +220,6 @@ export function drawSections(sections: Section[]) {
y += layout.background.gap; y += layout.background.gap;
} }
// watermark
ctx.drawImage(watermark, canvas.width - watermark.width - 1, 1); ctx.drawImage(watermark, canvas.width - watermark.width - 1, 1);
return canvas; return canvas;

View file

@ -1,13 +1,16 @@
import { type CodeDiff, DiffType } from "./types"; import { type CodeDiff, DiffType } from "./types";
export default { export default {
code: new Map([ raw: new Map(),
semantic: new Map(),
icons: new Map(),
code: new Map<string, CodeDiff>([
[ [
"app/foomodule.tsx", "app/foomodule.tsx",
{ {
type: DiffType.Added, type: DiffType.Added,
size: 24782, size: 24782,
} as CodeDiff, },
], ],
[ [
"app/ui/barmodule.ts", "app/ui/barmodule.ts",
@ -15,14 +18,14 @@ export default {
type: DiffType.Renamed, type: DiffType.Renamed,
oldName: "app/legacy/ui/bar_module.ts", oldName: "app/legacy/ui/bar_module.ts",
size: 5212, size: 5212,
} as CodeDiff, },
], ],
[ [
"app/intl/ReallyBigCoolComponent.tsx", "app/intl/ReallyBigCoolComponent.tsx",
{ {
type: DiffType.Removed, type: DiffType.Removed,
size: 46532, size: 46532,
} as CodeDiff, },
], ],
]), ]),
}; };

View file

@ -35,8 +35,11 @@ export type CodeDiff =
size: number; size: number;
}; };
export type Differs = Record<"semantic" | "raw" | "icons", Map<string, Diff> | undefined> & { export type Differs = {
code: Map<string, CodeDiff> | undefined; semantic: Map<string, Diff>;
raw: Map<string, Diff>;
icons: Map<string, Diff>;
code: Map<string, CodeDiff>;
}; };
export type RawColors = Record<string, string>; export type RawColors = Record<string, string>;

View file

@ -6,7 +6,11 @@ export const gitChanged = new Set<string>();
export async function fetchGitChanged() { export async function fetchGitChanged() {
for (const cwd of ["../data", "../canvas"] as const) { for (const cwd of ["../data", "../canvas"] as const) {
try { try {
const out = await Bun.$`git status -z`.cwd(cwd).quiet().nothrow().then((r) => r.text()); const out = await Bun.$`git status -z`
.cwd(cwd)
.quiet()
.nothrow()
.then((r) => r.text());
for (const changed of out for (const changed of out
.split("\x00") .split("\x00")
.filter((x) => x !== "") .filter((x) => x !== "")
@ -16,35 +20,26 @@ export async function fetchGitChanged() {
} }
} }
const gitQueue: Promise<void>[] = []; let lastCommit: Promise<void> = Promise.resolve();
export async function commit(files: string[], message: string, cwd = "../data") { export async function commit(files: string[], message: string, cwd = "../data") {
while (gitQueue[0]) { lastCommit = lastCommit.then(async () => {
await gitQueue[0]; if (process.env.NODE_ENV === "test" && !commitAnyway) {
} await fetchGitChanged();
} else {
const { promise, resolve } = Promise.withResolvers<void>(); await Bun.$`git restore --staged .`.cwd(cwd).nothrow().quiet().then(handleShellErr);
gitQueue.push(promise); if (cwd === "../data") await fetchGitChanged();
await Bun.$`git add ${{ raw: files.map((x) => Bun.$.escape(x)).join(" ") }}`
if (process.env.NODE_ENV === "test" && !commitAnyway) { .cwd(cwd)
await fetchGitChanged(); .nothrow()
} else { .quiet()
await Bun.$`git restore --staged .`.cwd(cwd).nothrow().quiet().then(handleShellErr); .then(handleShellErr);
if (cwd === "../data") await fetchGitChanged(); await Bun.$`git commit -m ${message}`
await Bun.$`git add ${{ raw: files.map((x) => Bun.$.escape(x)).join(" ") }}` .cwd(cwd)
.cwd(cwd) .nothrow()
.nothrow() .quiet()
.quiet() .then((e) => void e);
.then(handleShellErr); }
await Bun.$`git commit -m ${message}` });
.cwd(cwd) return lastCommit;
.nothrow()
.quiet()
.then((e) => void e);
}
resolve();
const promInd = gitQueue.indexOf(promise);
if (promInd !== -1) gitQueue.splice(promInd, 1);
} }

View file

@ -1,4 +1,3 @@
import { exists, rm } from "node:fs/promises";
import mock from "../mock"; import mock from "../mock";
import type { Differs } from "../types"; import type { Differs } from "../types";
import { commit } from "./git"; import { commit } from "./git";
@ -8,7 +7,6 @@ import codeTask from "./tasks/code";
import colorsTask from "./tasks/colors"; import colorsTask from "./tasks/colors";
import decompile from "./tasks/decompile"; import decompile from "./tasks/decompile";
import diffs from "./tasks/diffs"; import diffs from "./tasks/diffs";
import iconsTask from "./tasks/icons";
import { formatError, handleShellErr, join } from "./utils"; import { formatError, handleShellErr, join } from "./utils";
export async function runTasks() { export async function runTasks() {
@ -40,7 +38,9 @@ export async function runTasks() {
}, },
true, true,
); );
let differs: Differs | undefined; let differs: Differs = { raw: new Map(), semantic: new Map(), icons: new Map(), code: new Map() };
let gzipDone: Promise<void> | undefined;
if (!isMock) { if (!isMock) {
try { try {
@ -50,11 +50,19 @@ export async function runTasks() {
await Bun.$`git reset --hard`.cwd("../data").nothrow().quiet().then(handleShellErr); await Bun.$`git reset --hard`.cwd("../data").nothrow().quiet().then(handleShellErr);
await Bun.write("../data/version.txt", version); await Bun.write("../data/version.txt", version);
// canvas branch may not exist in test // canvas branch may not exist in test
await Bun.$`git reset --hard`.cwd("../canvas").nothrow().quiet().then(() => {}); await Bun.$`git reset --hard`
.cwd("../canvas")
.nothrow()
.quiet()
.then(() => {});
await Bun.write("../canvas/version.txt", version).catch(() => {}); await Bun.write("../canvas/version.txt", version).catch(() => {});
await Bun.$`git restore --staged .`.cwd("../data").nothrow().quiet().then(handleShellErr); await Bun.$`git restore --staged .`.cwd("../data").nothrow().quiet().then(handleShellErr);
await Bun.$`git restore --staged .`.cwd("../canvas").nothrow().quiet().then(() => {}); await Bun.$`git restore --staged .`
.cwd("../canvas")
.nothrow()
.quiet()
.then(() => {});
progress.update("preinit_discard", true); progress.update("preinit_discard", true);
@ -76,11 +84,11 @@ export async function runTasks() {
} }
try { try {
await wrapPromise( gzipDone = (await wrapPromise(
decompile(progress, join(apksFolder, "base", "assets", "index.android.bundle")), decompile(progress, join(apksFolder, "base", "assets", "index.android.bundle")),
progress, progress,
"decompile", "decompile",
); )) as Promise<void> | undefined;
} catch (e) { } catch (e) {
throw new Error(`Failed to decompile!\n${e}`); throw new Error(`Failed to decompile!\n${e}`);
} }
@ -88,8 +96,7 @@ export async function runTasks() {
const code = (await Bun.file(codePath).text()).replace(/\r/g, "").split("\n"); const code = (await Bun.file(codePath).text()).replace(/\r/g, "").split("\n");
await wrapPromise(codeTask(progress, code), progress, "code"); await wrapPromise(codeTask(progress, code), progress, "code");
if (progress.someFailed("code")) if (progress.someFailed("code")) throw new Error(`Failed at parser tasks!\n${progress.prettyErrors("code")}`);
throw new Error(`Failed at parser tasks!\n${progress.prettyErrors("code")}`);
// colors is non-critical, run in background // colors is non-critical, run in background
wrapPromise(colorsTask(code), progress, "colors").catch((e) => { wrapPromise(colorsTask(code), progress, "colors").catch((e) => {
progress.update("colors", false, String(e)); progress.update("colors", false, String(e));
@ -103,22 +110,25 @@ export async function runTasks() {
progress.update("icons", null); progress.update("icons", null);
progress.update("diff_icons", null); progress.update("diff_icons", null);
let waits = 0; if (gzipDone) {
while (!progress.isFinished("decompile_gzip") && waits < 120) { try {
await Bun.sleep(1000); await gzipDone;
waits++; } catch (e) {
} if (progress.someFailed("decompile_gzip"))
if (!progress.isFinished("decompile_gzip")) { throw new Error(`Failed at the decompile gzip task!\n${progress.prettyErrors("decompile_gzip")}`);
console.warn("decompile_gzip still not finished after 120s, continuing anyway"); throw e;
progress.update("decompile_gzip", true); }
} }
if (progress.someFailed("decompile_gzip")) if (progress.someFailed("decompile_gzip"))
throw new Error(`Failed at the decompile gzip task!\n${progress.prettyErrors("decompile_gzip")}`); throw new Error(`Failed at the decompile gzip task!\n${progress.prettyErrors("decompile_gzip")}`);
try { try {
differs = await diffs(progress); const result = await diffs(progress);
progress.update("diff", true); if (result) {
differs = result;
progress.update("diff", true);
}
} catch (e: any) { } catch (e: any) {
progress.update("diff", false, formatError(e)); progress.update("diff", false, formatError(e));
throw new Error(`Failed to generate diffs!\n${formatError(e)}`); throw new Error(`Failed to generate diffs!\n${formatError(e)}`);
@ -127,7 +137,7 @@ export async function runTasks() {
differs = mock; differs = mock;
} }
if (differs && !isQuiet) { if (!isQuiet) {
try { try {
const { webhook } = await import("./tasks/webhook"); const { webhook } = await import("./tasks/webhook");
await wrapPromise(webhook(differs), progress, "webhook"); await wrapPromise(webhook(differs), progress, "webhook");

View file

@ -1,4 +1,4 @@
import { mkdir, rm, readdir, stat } from "node:fs/promises"; import { mkdir, readdir, rm, stat } from "node:fs/promises";
import { join as pathJoin } from "node:path"; import { join as pathJoin } from "node:path";
import { commit } from "../git"; import { commit } from "../git";
import type { Progress } from "../progress"; import type { Progress } from "../progress";
@ -13,7 +13,10 @@ function extractModuleId(filename: string): number | undefined {
} }
function sanitizePath(p: string): string { function sanitizePath(p: string): string {
return p.replace(/\\/g, "/").replace(/^\.\.\//, "").replace(/[<>:"|?*]/g, "_"); return p
.replace(/\\/g, "/")
.replace(/^\.\.\//, "")
.replace(/[<>:"|?*]/g, "_");
} }
export default async function code(progress: Progress, _code: string[]) { export default async function code(progress: Progress, _code: string[]) {

View file

@ -28,19 +28,34 @@ function evalRawModule(snippet: string): any {
code = code.replace(/set\.fileFinishedImporting/g, "__importSet.fileFinishedImporting"); code = code.replace(/set\.fileFinishedImporting/g, "__importSet.fileFinishedImporting");
code = code.replace(/export\s+const\s+_private/, "const _private"); code = code.replace(/export\s+const\s+_private/, "const _private");
code = code + "\n; _private;"; code = code + "\n; _private;";
return runInNewContext(code, {}); const result = runInNewContext(code, {});
if (!result || typeof result !== "object" || !result.RawColors || typeof result.RawColors !== "object") {
throw new Error(
"VM evaluation failed: RawColors not found or invalid shape. The regex transforms may not match the current decompiler output.",
);
}
return result;
} }
function evalSemanticModule(snippet: string): any { function evalSemanticModule(snippet: string): any {
let code = snippet; let code = snippet;
code = code.replace(/^import set from "set".*$/m, "const __importSet = { fileFinishedImporting: () => {} };"); code = code.replace(/^import set from "set".*$/m, "const __importSet = { fileFinishedImporting: () => {} };");
code = code.replace(/^import items from "items".*$/m, "const items = { _private: { Themes: new Proxy({}, { get(_, k){ return k.toString().toLowerCase(); } }) } };"); code = code.replace(
/^import items from "items".*$/m,
"const items = { _private: { Themes: new Proxy({}, { get(_, k){ return k.toString().toLowerCase(); } }) } };",
);
code = code.replace(/^import.*$/gm, ""); code = code.replace(/^import.*$/gm, "");
code = code.replace(/set\.fileFinishedImporting/g, "__importSet.fileFinishedImporting"); code = code.replace(/set\.fileFinishedImporting/g, "__importSet.fileFinishedImporting");
code = code.replace(/export\s+const\s+_private/, "const _private"); code = code.replace(/export\s+const\s+_private/, "const _private");
code = code + "\n; _private;"; code = code + "\n; _private;";
code = fixHermesDecIndex(code); code = fixHermesDecIndex(code);
return runInNewContext(code, {}); const result = runInNewContext(code, {});
if (!result || typeof result !== "object" || !result.SemanticColors || typeof result.SemanticColors !== "object") {
throw new Error(
"VM evaluation failed: SemanticColors not found or invalid shape. The regex transforms may not match the current decompiler output.",
);
}
return result;
} }
export function getInternalRawColors(code: string[]) { export function getInternalRawColors(code: string[]) {
@ -59,7 +74,9 @@ export function getInternalSemanticColors(code: string[], raw: Record<string, st
if (!snippet) throw new Error("Cannot find semantic definitions module"); if (!snippet) throw new Error("Cannot find semantic definitions module");
const mod = evalSemanticModule(snippet); const mod = evalSemanticModule(snippet);
const semantic: SemanticColors = {}; const semantic: SemanticColors = {};
for (const [key, colors] of Object.entries(mod.SemanticColors as Record<string, Record<string, { raw: string; opacity: number }>>)) { for (const [key, colors] of Object.entries(
mod.SemanticColors as Record<string, Record<string, { raw: string; opacity: number }>>,
)) {
const color: SemanticColors[string] = {}; const color: SemanticColors[string] = {};
for (const [theme, val] of Object.entries(colors)) { for (const [theme, val] of Object.entries(colors)) {
if (typeof val !== "object" || !("raw" in val)) continue; if (typeof val !== "object" || !("raw" in val)) continue;
@ -88,5 +105,9 @@ export default async function colors(code: string[]) {
await Bun.write("../canvas/semantic.json", JSON.stringify(sortObj(semantic), null, 4)); await Bun.write("../canvas/semantic.json", JSON.stringify(sortObj(semantic), null, 4));
await Bun.write("../canvas/semantic_simple.json", JSON.stringify(sortObj(convertSimpleSemantic(semantic)), null, 4)); await Bun.write("../canvas/semantic_simple.json", JSON.stringify(sortObj(convertSimpleSemantic(semantic)), null, 4));
await commit(["raw.json", "semantic.json", "semantic_simple.json"], `chore: update colors for ${cuteVersion}`, "../canvas"); await commit(
["raw.json", "semantic.json", "semantic_simple.json"],
`chore: update colors for ${cuteVersion}`,
"../canvas",
);
} }

View file

@ -26,18 +26,11 @@ export default async function decompile(progress: Progress, pathToBundle: string
progress.start("decompile_decompiling"); progress.start("decompile_decompiling");
const decompilerBin = join(pathToDecompiler, "target/release/hermes-decomp"); const decompilerBin = join(pathToDecompiler, "target/release/hermes-decomp");
if (!(await Bun.file(decompilerBin).exists())) { if (!(await Bun.file(decompilerBin).exists())) {
await Bun.$`cargo build --release -p hbc-decomp-cli` await Bun.$`cargo build --release -p hbc-decomp-cli`.cwd(pathToDecompiler).quiet().nothrow().then(handleShellErr);
.cwd(pathToDecompiler)
.quiet()
.nothrow()
.then(handleShellErr);
} }
if (!(await Bun.file(codePath).exists())) { if (!(await Bun.file(codePath).exists())) {
await Bun.$`${decompilerBin} decompile ${pathToBundle} --output ${codePath}` await Bun.$`${decompilerBin} decompile ${pathToBundle} --output ${codePath}`.quiet().nothrow().then(handleShellErr);
.quiet()
.nothrow()
.then(handleShellErr);
} }
if (!(await exists(modulesPath))) { if (!(await exists(modulesPath))) {
@ -70,15 +63,20 @@ export default async function decompile(progress: Progress, pathToBundle: string
const gzipper = new Worker(gzipWorkerURL); const gzipper = new Worker(gzipWorkerURL);
progress.start("decompile_gzip"); progress.start("decompile_gzip");
const { promise: gzipDone, resolve: resolveGzip } = Promise.withResolvers<void>();
gzipper.addEventListener("message", async ({ data }) => { gzipper.addEventListener("message", async ({ data }) => {
if (data === true) { if (data === true) {
await commit([gzFile, "module-paths.json", "index.android.bundle"], `chore: update decompiled code for ${cuteVersion}`); await commit(
[gzFile, "module-paths.json", "index.android.bundle"],
`chore: update decompiled code for ${cuteVersion}`,
);
progress.update("decompile_gzip", true); progress.update("decompile_gzip", true);
} }
gzipper.terminate(); gzipper.terminate();
resolveGzip();
}); });
gzipper.postMessage({ path: codePath, target: join("../data", gzFile) }); gzipper.postMessage({ path: codePath, target: join("../data", gzFile) });
} else { return gzipDone;
progress.update("decompile_gzip", null);
} }
progress.update("decompile_gzip", null);
} }

View file

@ -12,185 +12,183 @@ import { type Progress, wrapPromise } from "../progress";
import { diffAnyway, prevFiles } from "../shared"; import { diffAnyway, prevFiles } from "../shared";
import { join, parseJsonl } from "../utils"; import { join, parseJsonl } from "../utils";
async function diffRaw() { interface DiffOpts<TOld, TNew> {
if (!prevFiles.has("raw.json")) { getSource: (key: string, val: TNew) => string;
throw new Error("Missing prevFile: raw.json"); getLabel?: (key: string, val: TNew) => string;
} getOldSource?: (key: string, val: TOld) => string;
getOldLabel?: (key: string, val: TOld) => string;
isRename: (newKey: string, oldVal: TOld, newVal: TNew) => boolean;
isChanged?: (oldVal: TOld, newVal: TNew) => boolean;
}
const oldRaw = JSON.parse(new TextDecoder().decode(prevFiles.get("raw.json"))) as RawColors; function diffEntries<TOld, TNew>(
const newRaw: RawColors = await Bun.file(join("../canvas", "raw.json")).json(); oldEntries: Record<string, TOld>,
newEntries: Record<string, TNew>,
const renamed = new Set<string>(); opts: DiffOpts<TOld, TNew>,
): Map<string, Diff> {
const changes = new Map<string, Diff>(); const changes = new Map<string, Diff>();
for (const [name, source] of Object.entries(newRaw)) const renamed = new Set<string>();
if (!oldRaw[name]) {
const oldName = Object.entries(oldRaw).find(([key, val]) => !newRaw[key] && val === source)?.[0];
if (!oldName) for (const [name, newVal] of Object.entries(newEntries)) {
const oldVal = oldEntries[name];
if (oldVal === undefined) {
const oldName = Object.entries(oldEntries).find(
([key, val]) => !(key in newEntries) && opts.isRename(name, val, newVal),
)?.[0];
if (!oldName) {
changes.set(name, { changes.set(name, {
type: DiffType.Added, type: DiffType.Added,
source, source: opts.getSource(name, newVal),
label: opts.getLabel?.(name, newVal),
}); });
else { } else {
renamed.add(oldName); renamed.add(oldName);
changes.set(name, { changes.set(name, { type: DiffType.Renamed, oldName, source: opts.getSource(name, newVal) });
type: DiffType.Renamed,
oldName,
source,
});
} }
} else if (oldRaw[name].toLowerCase() !== source.toLowerCase()) } else if (opts.isChanged?.(oldVal, newVal)) {
changes.set(name, { changes.set(name, {
type: DiffType.Changed, type: DiffType.Changed,
source, source: opts.getSource(name, newVal),
oldSource: oldRaw[name], label: opts.getLabel?.(name, newVal),
}); oldSource: opts.getOldSource?.(name, oldVal) ?? "",
for (const [name, source] of Object.entries(oldRaw)) oldLabel: opts.getOldLabel?.(name, oldVal),
if (!(newRaw[name] || renamed.has(name)))
changes.set(name, {
type: DiffType.Removed,
source,
}); });
}
}
for (const [name, oldVal] of Object.entries(oldEntries)) {
if (name in newEntries || renamed.has(name)) continue;
changes.set(name, {
type: DiffType.Removed,
source: opts.getOldSource?.(name, oldVal) ?? "",
label: opts.getOldLabel?.(name, oldVal),
});
}
return changes; return changes;
} }
function getSemanticLabel(semantic: any) { function diffEntriesCode(
const light = semantic.light, oldEntries: Record<string, { size: number; compSize: number }>,
dark = semantic.darker ?? semantic.dark; newEntries: Record<string, { size: number; compSize: number }>,
if (dark && light) return `☀️ ${light[0]}, 🌙 ${dark[0]}`; ): Map<string, CodeDiff> {
const changes = new Map<string, CodeDiff>();
const renamed = new Set<string>();
for (const [name, newVal] of Object.entries(newEntries)) {
if (!(name in oldEntries)) {
const oldName = Object.entries(oldEntries).find(
([key, val]) => !(key in newEntries) && val.compSize === newVal.compSize,
)?.[0];
if (!oldName) {
changes.set(name, { type: DiffType.Added, size: newVal.size });
} else {
renamed.add(oldName);
changes.set(name, { type: DiffType.Renamed, oldName, size: newVal.size });
}
}
}
for (const [name, oldVal] of Object.entries(oldEntries)) {
if (!(name in newEntries || renamed.has(name))) {
changes.set(name, { type: DiffType.Removed, size: oldVal.size });
}
}
return changes;
}
async function diffRaw() {
if (!prevFiles.has("raw.json")) throw new Error("Missing prevFile: raw.json");
const oldRaw = JSON.parse(new TextDecoder().decode(prevFiles.get("raw.json"))) as RawColors;
const newRaw: RawColors = await Bun.file(join("../canvas", "raw.json")).json();
return diffEntries(oldRaw, newRaw, {
getSource: (_, val) => val,
isRename: (newKey, oldVal, newVal) => !(newKey in newRaw) && oldVal === newVal,
isChanged: (oldVal, newVal) => oldVal.toLowerCase() !== newVal.toLowerCase(),
});
}
function getSemanticLabel(semantic: Record<string, [string, unknown]>) {
const light = semantic.light;
const dark = semantic.darker ?? semantic.dark;
if (dark && light) return `${light[0]}, ${dark[0]}`;
return Object.entries(semantic) return Object.entries(semantic)
.map(([name, [color]]) => `${name} ${color}`) .map(([name, [color]]) => `${name} ${color}`)
.join(", "); .join(", ");
} }
async function diffSemantic() { async function diffSemantic() {
if (!prevFiles.has("semantic.json")) { if (!prevFiles.has("semantic.json")) throw new Error("Missing prevFile: semantic.json");
throw new Error("Missing prevFile: semantic.json");
}
const oldSemantic = JSON.parse(new TextDecoder().decode(prevFiles.get("semantic.json"))) as SemanticColors; const oldSemantic = JSON.parse(new TextDecoder().decode(prevFiles.get("semantic.json"))) as SemanticColors;
const newSemantic: SemanticColors = await Bun.file(join("../canvas", "semantic.json")).json(); const newSemantic: SemanticColors = await Bun.file(join("../canvas", "semantic.json")).json();
const renamed = new Set<string>(); const changes = diffEntries(oldSemantic, newSemantic, {
const changes = new Map<string, Diff>(); getSource: (_, val) =>
for (const [name, semantic] of Object.entries(newSemantic)) { Object.values(val)
const source = Object.values(semantic) .map(([color]) => color)
.map(([color]) => color) .join(","),
.join(","); getLabel: (_, val) => getSemanticLabel(val),
if (!oldSemantic[name]) { isRename: (newKey, oldVal, newVal) => {
const themes = Object.keys(semantic).length; const themes = Object.keys(newVal).length;
const oldName = Object.entries(oldSemantic).find( return (
([key, val]) => !(newKey in newSemantic) &&
!newSemantic[key] && Object.keys(oldVal).length === themes &&
Object.keys(val).length === themes && Object.entries(oldVal).every(([skey, sval]) => sval[0] === newVal[skey]?.[0])
Object.entries(val).every(([skey, sval]) => sval[0] === semantic[skey][0]), );
)?.[0]; },
});
if (!oldName) for (const [name, semantic] of Object.entries(newSemantic)) {
changes.set(name, { if (oldSemantic[name]) {
type: DiffType.Added, for (const [theme, [color]] of Object.entries(semantic)) {
source, if (!oldSemantic[name][theme]) {
label: getSemanticLabel(semantic), changes.set(`${name}.${theme}`, { type: DiffType.Added, source: color });
}); } else if (oldSemantic[name][theme][0].toLowerCase() !== color.toLowerCase()) {
else {
renamed.add(oldName);
changes.set(name, {
type: DiffType.Renamed,
oldName,
source,
});
}
} else
for (const [theme, [color]] of Object.entries(semantic))
if (!oldSemantic[name][theme])
changes.set(`${name}.${theme}`, {
type: DiffType.Added,
source: color,
});
else if (oldSemantic[name][theme][0].toLowerCase() !== color.toLowerCase())
changes.set(`${name}.${theme}`, { changes.set(`${name}.${theme}`, {
type: DiffType.Changed, type: DiffType.Changed,
source: color, source: color,
oldSource: oldSemantic[name][theme][0], oldSource: oldSemantic[name][theme][0],
}); });
}
}
}
} }
for (const [name, semantic] of Object.entries(oldSemantic)) {
if (renamed.has(name)) continue;
const source = Object.values(semantic) for (const [name, semantic] of Object.entries(oldSemantic)) {
.map(([color]) => color) if (name in newSemantic) {
.join(","); for (const [theme, [source]] of Object.entries(semantic)) {
if (!newSemantic[name]) if (!newSemantic[name][theme]) {
changes.set(name, { changes.set(`${name}.${theme}`, { type: DiffType.Removed, source });
type: DiffType.Removed, }
source, }
label: getSemanticLabel(semantic), }
});
else
for (const [theme, [source]] of Object.entries(semantic))
if (!newSemantic[name][theme])
changes.set(`${name}.${theme}`, {
type: DiffType.Removed,
source,
});
} }
return changes; return changes;
} }
async function diffIcons() { async function diffIcons() {
if (!prevFiles.has("icons.json")) { if (!prevFiles.has("icons.json")) throw new Error("Missing prevFile: icons.json");
throw new Error("Missing prevFile: icons.json");
}
const oldIcons = JSON.parse(new TextDecoder().decode(prevFiles.get("icons.json"))) as Icons; const oldIcons = JSON.parse(new TextDecoder().decode(prevFiles.get("icons.json"))) as Icons;
const newIcons: Icons = await Bun.file(join("../canvas", "icons.json")).json(); const newIcons: Icons = await Bun.file(join("../canvas", "icons.json")).json();
const iconDir = { const iconDir = { old: join("../canvas", "oldicons"), new: join("../canvas", "icons") };
old: join("../canvas", "oldicons"),
new: join("../canvas", "icons"),
};
const renamed = new Set<string>(); return diffEntries(oldIcons, newIcons, {
const changes = new Map<string, Diff>(); getSource: (_, val) => join(iconDir.new, val.file),
for (const [name, icon] of Object.entries(newIcons)) { getLabel: (_, val) => val.hash.slice(0, 8),
const label = icon.hash.slice(0, 8); getOldSource: (_, val) => join(iconDir.old, val.file),
if (!oldIcons[name]) { getOldLabel: (_, val) => val.hash.slice(0, 8),
const oldName = Object.entries(oldIcons).find(([key, val]) => !newIcons[key] && val.hash === icon.hash)?.[0]; isRename: (newKey, oldVal, newVal) => !(newKey in newIcons) && oldVal.hash === newVal.hash,
isChanged: (oldVal, newVal) => oldVal.hash !== newVal.hash,
if (!oldName) });
changes.set(name, {
type: DiffType.Added,
source: join(iconDir.new, icon.file),
label,
});
else {
renamed.add(oldName);
changes.set(name, {
type: DiffType.Renamed,
oldName,
source: join(iconDir.new, icon.file),
});
}
} else if (oldIcons[name].hash !== icon.hash)
changes.set(name, {
type: DiffType.Changed,
source: join(iconDir.new, icon.file),
label,
oldSource: join(iconDir.old, oldIcons[name].file),
oldLabel: oldIcons[name].hash.slice(0, 8),
});
}
for (const [name, icon] of Object.entries(oldIcons))
if (!(newIcons[name] || renamed.has(name)))
changes.set(name, {
type: DiffType.Removed,
source: join(iconDir.old, icon.file),
label: icon.hash.slice(0, 8),
});
return changes;
} }
function parseSource(text: string) { function parseSource(text: string) {
@ -201,42 +199,12 @@ function parseSource(text: string) {
} }
async function diffCode() { async function diffCode() {
if (!prevFiles.has("source.jsonl")) { if (!prevFiles.has("source.jsonl")) throw new Error("Missing prevFile: source.jsonl");
throw new Error("Missing prevFile: source.jsonl");
}
const oldCode = parseSource(new TextDecoder().decode(prevFiles.get("source.jsonl"))); const oldCode = parseSource(new TextDecoder().decode(prevFiles.get("source.jsonl")));
const newCode = parseSource(await Bun.file(join("../data", "source.jsonl")).text()); const newCode = parseSource(await Bun.file(join("../data", "source.jsonl")).text());
const renamed = new Set<string>(); return diffEntriesCode(oldCode, newCode);
const changes = new Map<string, CodeDiff>();
for (const [name, { size, compSize }] of Object.entries(newCode)) {
if (!oldCode[name]) {
const oldName = Object.entries(oldCode).find(([key, val]) => !newCode[key] && val.compSize === compSize)?.[0];
if (!oldName)
changes.set(name, {
type: DiffType.Added,
size,
});
else {
renamed.add(oldName);
changes.set(name, {
type: DiffType.Renamed,
oldName,
size,
});
}
}
}
for (const [name, { size }] of Object.entries(oldCode))
if (!(newCode[name] || renamed.has(name)))
changes.set(name, {
type: DiffType.Removed,
size,
});
return changes;
} }
export default async function diffs(progress: Progress) { export default async function diffs(progress: Progress) {
@ -251,7 +219,7 @@ export default async function diffs(progress: Progress) {
return; return;
} }
const differs = {} as Differs; const differs: Differs = { raw: new Map(), semantic: new Map(), icons: new Map(), code: new Map() };
await Promise.all([ await Promise.all([
gitChanged.has("raw.json") gitChanged.has("raw.json")

View file

@ -12,7 +12,7 @@ import { drawSections } from "../../canvas";
import { makeSections } from "../../canvas/factory"; import { makeSections } from "../../canvas/factory";
import { type CodeDiff, type Diff, type Differs, DiffType } from "../../types"; import { type CodeDiff, type Diff, type Differs, DiffType } from "../../types";
import { cuteVersion, maxDiffChanges, version } from "../shared"; import { cuteVersion, maxDiffChanges, version } from "../shared";
import { assert, formatBytes, sortEntries } from "../utils"; import { formatBytes, sortEntries } from "../utils";
function fileBase(path: string, other?: string) { function fileBase(path: string, other?: string) {
if (!other) return `... ${basename(path)}`; if (!other) return `... ${basename(path)}`;
@ -49,43 +49,39 @@ interface FormattedDiff {
} }
function formatDiff(diffs: Map<string, Diff | CodeDiff>): FormattedDiff { function formatDiff(diffs: Map<string, Diff | CodeDiff>): FormattedDiff {
const entries = [...diffs.entries()]; const entries = sortEntries([...diffs.entries()]);
const sections = { const sections = {
Added: sortEntries(entries) Added: entries
.map( .filter(([, diff]) => diff.type === DiffType.Added)
([name, diff]) => .map(([name, diff]) => {
diff.type === DiffType.Added && if ("size" in diff) return `+ ${fileBase(name)} (${formatBytes(diff.size)})`;
("size" in diff const d = diff as Extract<Diff, { type: DiffType.Added | DiffType.Removed }>;
? `+ ${fileBase(name)} (${formatBytes(diff.size)})` return `+ ${name}: ${d.label || d.source}`;
: `+ ${name}: ${diff.label || diff.source}`), }),
) Changed: entries
.filter((x) => typeof x === "string"), .filter(([, diff]) => diff.type === DiffType.Changed)
Changed: sortEntries(entries) .map(([name, diff]) => {
.map( const d = diff as Extract<Diff, { type: DiffType.Changed }>;
([name, diff]) => return `- ${name}: ${d.oldLabel || d.oldSource}\n+ ${name}: ${d.label || d.source}`;
diff.type === DiffType.Changed && }),
`- ${name}: ${diff.oldLabel || diff.oldSource}\n+ ${name}: ${diff.label || diff.source}`, Renamed: entries
) .filter(([, diff]) => diff.type === DiffType.Renamed)
.filter((x) => typeof x === "string"), .map(([name, diff]) => {
Renamed: sortEntries(entries) if ("size" in diff) {
.map( const cd = diff as Extract<CodeDiff, { type: DiffType.Renamed }>;
([name, diff]) => return `- ${fileBase(cd.oldName, name)}\n+ ${fileBase(name, cd.oldName)}`;
diff.type === DiffType.Renamed && }
("size" in diff const d = diff as Extract<Diff, { type: DiffType.Renamed }>;
? `- ${fileBase(diff.oldName, name)}\n+ ${fileBase(name, diff.oldName)}` return `- ${d.oldName}\n+ ${name}`;
: `- ${diff.oldName}\n+ ${name}`), }),
) Removed: entries
.filter((x) => typeof x === "string"), .filter(([, diff]) => diff.type === DiffType.Removed)
Removed: sortEntries(entries) .map(([name, diff]) => {
.map( if ("size" in diff) return `- ${fileBase(name)} (${formatBytes(diff.size)})`;
([name, diff]) => const d = diff as Extract<Diff, { type: DiffType.Added | DiffType.Removed }>;
diff.type === DiffType.Removed && return `- ${name}: ${d.label || d.source}`;
("size" in diff }),
? `- ${fileBase(name)} (${formatBytes(diff.size)})`
: `- ${name}: ${diff.label || diff.source}`),
)
.filter((x) => typeof x === "string"),
}; };
const text = Object.entries(sections).filter(([, lines]) => lines.length); const text = Object.entries(sections).filter(([, lines]) => lines.length);
@ -214,7 +210,7 @@ async function sendWebhook(webhook: string, role: string, embeds: WebhookEmbed[]
} }
export async function webhook(diffs: Differs) { export async function webhook(diffs: Differs) {
if (diffs.raw?.size || diffs.semantic?.size) { if (diffs.raw.size || diffs.semantic.size) {
const wh = process.env.color_webhook; const wh = process.env.color_webhook;
if (!wh) { if (!wh) {
console.warn("Missing color_webhook, skipping color webhook"); console.warn("Missing color_webhook, skipping color webhook");
@ -223,14 +219,14 @@ export async function webhook(diffs: Differs) {
wh, wh,
"1227327297795657850", "1227327297795657850",
[ [
diffs.raw?.size && { diffs.raw.size && {
title: "Raw colors", title: "Raw colors",
body: formatDiff(diffs.raw), body: formatDiff(diffs.raw),
image: drawSections(await makeSections(diffs.raw)), image: drawSections(await makeSections(diffs.raw)),
footer: makeFooter(diffs.raw.size, "raw color"), footer: makeFooter(diffs.raw.size, "raw color"),
key: "raw-colors", key: "raw-colors",
}, },
diffs.semantic?.size && { diffs.semantic.size && {
title: "Semantic colors", title: "Semantic colors",
body: formatDiff(diffs.semantic), body: formatDiff(diffs.semantic),
image: drawSections(await makeSections(diffs.semantic)), image: drawSections(await makeSections(diffs.semantic)),
@ -243,7 +239,7 @@ export async function webhook(diffs: Differs) {
} }
} }
if (diffs.icons?.size) { if (diffs.icons.size) {
const wh = process.env.icons_webhook; const wh = process.env.icons_webhook;
if (!wh) { if (!wh) {
console.warn("Missing icons_webhook, skipping"); console.warn("Missing icons_webhook, skipping");
@ -265,7 +261,7 @@ export async function webhook(diffs: Differs) {
} }
} }
if (diffs.code?.size) { if (diffs.code.size) {
const wh = process.env.code_webhook; const wh = process.env.code_webhook;
if (!wh) { if (!wh) {
console.warn("Missing code_webhook, skipping"); console.warn("Missing code_webhook, skipping");