feat: per-channel release processing (alpha/beta/stable) + single sequential workflow

This commit is contained in:
lvwmwm 2026-09-18 14:30:50 -03:00
parent 14c5a5d005
commit 9b2cf30e4f
14 changed files with 284 additions and 260 deletions

View file

@ -7,7 +7,7 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
force: force:
description: Force update regardless of "bun run check" description: Force update regardless of whether versions changed
default: false default: false
required: false required: false
type: boolean type: boolean
@ -20,7 +20,7 @@ on:
- cron: "30 * * * *" - cron: "30 * * * *"
jobs: jobs:
check-update: update:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -51,49 +51,27 @@ jobs:
git -C ../canvas config user.name github-actions git -C ../canvas config user.name github-actions
git -C ../canvas config user.email 41898282+github-actions[bot]@users.noreply.github.com git -C ../canvas config user.email 41898282+github-actions[bot]@users.noreply.github.com
- name: Check for version
id: check
working-directory: main
run: |
version=$(bun run check ${{ github.event.inputs.force == 'true' && '--force' || '' }})
echo "[DEBUG]: Check result: $version"
echo "version=$version" >> $GITHUB_OUTPUT
- name: Update - name: Update
if: ${{ steps.check.outputs.version != 'false' }}
env: env:
code_webhook: ${{ secrets.CODE_WEBHOOK }} code_webhook: ${{ secrets.CODE_WEBHOOK }}
bot_token: ${{ secrets.BOT_TOKEN }} bot_token: ${{ secrets.BOT_TOKEN }}
working-directory: main working-directory: main
run: bun run update:prod ${{ github.event.inputs.quiet == 'true' && '--quiet' || '' }} run: |
bun run src/update/index.ts ${{ github.event.inputs.force == 'true' && '--force' || '' }} ${{ github.event.inputs.quiet == 'true' && '--quiet' || '' }}
- name: Publish changes - name: Publish changes
if: ${{ steps.check.outputs.version != 'false' }}
working-directory: data working-directory: data
run: | run: git push
if [ -f .update-skipped ]; then
echo "Update skipped (decompile failed); not publishing."
exit 0
fi
git push
- name: Publish canvas - name: Publish canvas
if: ${{ steps.check.outputs.version != 'false' }}
working-directory: data working-directory: data
run: | run: git -C ../canvas push
if [ -f .update-skipped ]; then
echo "Update skipped (decompile failed); not publishing canvas."
exit 0
fi
git -C ../canvas push
- name: Tag changes - name: Tag changes
if: ${{ steps.check.outputs.version != 'false' }}
working-directory: data working-directory: data
run: | run: |
if [ -f .update-skipped ]; then for ch in alpha beta stable; do
echo "Update skipped (decompile failed); not tagging." v=$(cat ".update-ok-$ch" 2>/dev/null) || continue
exit 0 git tag -f "$ch-$v"
fi git push origin -f "$ch-$v"
git tag -f ${{ steps.check.outputs.version }} done
git push origin -f ${{ steps.check.outputs.version }}

View file

@ -2,7 +2,6 @@
"name": "themelings", "name": "themelings",
"type": "module", "type": "module",
"scripts": { "scripts": {
"check": "bun run src/check.ts",
"update:test": "bun run --env-file local.env src/update/index.ts", "update:test": "bun run --env-file local.env src/update/index.ts",
"update:commit": "bun run --env-file local.env src/update/index.ts --commit-anyway", "update:commit": "bun run --env-file local.env src/update/index.ts --commit-anyway",
"update:mock": "bun run --env-file local.env src/update/index.ts --mock", "update:mock": "bun run --env-file local.env src/update/index.ts --mock",

View file

@ -1,19 +0,0 @@
import { trackerUserAgent } from "./update/shared";
const tracker = await fetch("https://tracker.vendetta.rocks/tracker/index", {
headers: {
"User-Agent": trackerUserAgent,
},
});
if (!tracker.ok || tracker.status !== 200) throw new Error("Failed to get version from tracker!");
const data = (await tracker.json()) as { latest: { alpha: number } };
const alpha = data.latest.alpha.toString();
const file = Bun.file("../data/version.txt");
const latest = (await file.exists()) && (await file.text());
if (latest !== alpha || process.argv.includes("--force")) {
await file.write(alpha);
process.stdout.write(alpha);
} else process.stdout.write("false");

38
src/update/channel.ts Normal file
View file

@ -0,0 +1,38 @@
export const channels = ["alpha", "beta", "stable"] as const;
export type Channel = (typeof channels)[number];
export interface ChannelContext {
channel: Channel;
dataDir: string;
canvasDir: string;
workFolder: string;
apksFolder: string;
codePath: string;
modulesPath: string;
modulePathsDest: string;
version: string;
cuteVersion: string;
}
let current: ChannelContext | undefined;
export function setChannel(channel: Channel, version: string): ChannelContext {
current = {
channel,
dataDir: `../data/${channel}`,
canvasDir: `../canvas/${channel}`,
workFolder: `tmp/${channel}`,
apksFolder: `tmp/${channel}/apks`,
codePath: `tmp/${channel}/code.js`,
modulesPath: `tmp/${channel}/modules`,
modulePathsDest: `../data/${channel}/module-paths.json`,
version,
cuteVersion: `${channel} ${version.slice(0, 3)}.${Number(version.slice(-2))}`,
};
return current;
}
export function ctx(): ChannelContext {
if (!current) throw new Error("Channel context is not set");
return current;
}

View file

@ -1,3 +1,4 @@
import { channels } from "./channel";
import { commitAnyway } from "./shared"; import { commitAnyway } from "./shared";
import { handleShellErr } from "./utils"; import { handleShellErr } from "./utils";
@ -15,7 +16,7 @@ export async function fetchGitChanged() {
.split("\x00") .split("\x00")
.filter((x) => x !== "") .filter((x) => x !== "")
.map((x) => x.slice(3))) .map((x) => x.slice(3)))
if (changed) gitChanged.add(changed); if (changed && channels.some((channel) => changed.startsWith(`${channel}/`))) gitChanged.add(changed);
} catch {} } catch {}
} }
} }
@ -28,7 +29,7 @@ export async function commit(files: string[], message: string, cwd = "../data")
await fetchGitChanged(); await fetchGitChanged();
} else { } else {
await Bun.$`git restore --staged .`.cwd(cwd).nothrow().quiet().then(handleShellErr); await Bun.$`git restore --staged .`.cwd(cwd).nothrow().quiet().then(handleShellErr);
if (cwd === "../data") await fetchGitChanged(); if (cwd === "../data" || cwd.startsWith("../data/")) await fetchGitChanged();
await Bun.$`git add ${{ raw: files.map((x) => Bun.$.escape(x)).join(" ") }}` await Bun.$`git add ${{ raw: files.map((x) => Bun.$.escape(x)).join(" ") }}`
.cwd(cwd) .cwd(cwd)
.nothrow() .nothrow()

View file

@ -1,10 +1,21 @@
import { exists, mkdir, rm } from "node:fs/promises"; import { exists, mkdir, rm } from "node:fs/promises";
import { type Channel, type ChannelContext, channels, setChannel } from "./channel";
import { log, makeProgress, wrapPromise } from "./progress"; import { log, makeProgress, wrapPromise } from "./progress";
import { runTasks } from "./runner"; import { runTasks } from "./runner";
import { type apkSplits, apksFolder, isMock, trackerUserAgent, version, workFolder } from "./shared"; import { type apkSplits, force, isMock, trackerUserAgent } from "./shared";
import { handleShellErr, join } from "./utils"; import { handleShellErr, join } from "./utils";
const cdnUrl = `https://tracker.vendetta.rocks/tracker/download/${version}/`; const tracker = await fetch("https://tracker.vendetta.rocks/tracker/index", {
headers: {
"User-Agent": trackerUserAgent,
},
});
if (!tracker.ok || tracker.status !== 200) throw new Error("Failed to get version from tracker!");
const { latest } = (await tracker.json()) as { latest: Record<Channel, number> };
async function downloadApks(channel: ChannelContext) {
const cdnUrl = `https://tracker.vendetta.rocks/tracker/download/${channel.version}/`;
const mediaFiles = Bun.$.braces("res/**/*.{png,jpg,lottie}"); const mediaFiles = Bun.$.braces("res/**/*.{png,jpg,lottie}");
const apkAssets = { const apkAssets = {
@ -15,25 +26,22 @@ const apkAssets = {
let reuseFolder = isMock; let reuseFolder = isMock;
if (!reuseFolder) { if (!reuseFolder) {
const matches = await Bun.file("tmp/ver") const matches = await Bun.file(join(channel.workFolder, "ver"))
.text() .text()
.then((x) => x === version) .then((x) => x === channel.version)
.catch(() => false); .catch(() => false);
if (matches) { if (matches) {
const exist = await Promise.allSettled( const exist = await Promise.allSettled(
Object.keys(apkAssets).map((folder) => exists(join(apksFolder, folder, "res"))), Object.keys(apkAssets).map((folder) => exists(join(channel.apksFolder, folder, "res"))),
); );
if (exist.every((x) => x.status === "fulfilled" && x.value)) reuseFolder = true; if (exist.every((x) => x.status === "fulfilled" && x.value)) reuseFolder = true;
} }
} }
// Temp folder yippee! if (!reuseFolder) await rm(channel.workFolder, { force: true, recursive: true });
if (!(await exists(channel.apksFolder))) await mkdir(channel.apksFolder, { recursive: true });
if (!reuseFolder) await rm(workFolder, { force: true, recursive: true });
if (!(await exists(apksFolder))) await mkdir(apksFolder, { recursive: true });
if (!reuseFolder) { if (!reuseFolder) {
// Download the APKs
log("Downloading APKs..."); log("Downloading APKs...");
const downloadProgress = makeProgress( const downloadProgress = makeProgress(
@ -49,13 +57,14 @@ if (!reuseFolder) {
}, },
}) })
.then((res) => res.arrayBuffer()) .then((res) => res.arrayBuffer())
.then((data) => Bun.write(join(workFolder, `${apk}.zip`), data)), .then((data) => Bun.write(join(channel.workFolder, `${apk}.zip`), data)),
downloadProgress, downloadProgress,
apk, apk,
); );
}), }),
); );
if (downloadProgress.anyFailed()) throw new Error(`Failed to download all APKs!\n${downloadProgress.prettyErrors()}`); if (downloadProgress.anyFailed())
throw new Error(`Failed to download all APKs!\n${downloadProgress.prettyErrors()}`);
log("\nUnzipping APKs..."); log("\nUnzipping APKs...");
@ -66,9 +75,9 @@ if (!reuseFolder) {
await Promise.allSettled( await Promise.allSettled(
Object.entries(apkAssets).map(([apk, assets]) => Object.entries(apkAssets).map(([apk, assets]) =>
wrapPromise( wrapPromise(
Bun.$`unzip -o ${join(workFolder, `${apk}.zip`)} ${{ Bun.$`unzip -o ${join(channel.workFolder, `${apk}.zip`)} ${{
raw: assets.map((file) => Bun.$.escape(file)).join(" "), raw: assets.map((file) => Bun.$.escape(file)).join(" "),
}} -d ${join(apksFolder, apk)} 2>/dev/null` }} -d ${channel.apksFolder} 2>/dev/null`
.quiet() .quiet()
.nothrow() .nothrow()
.then(handleShellErr), .then(handleShellErr),
@ -79,11 +88,57 @@ if (!reuseFolder) {
); );
if (unzipProgress.anyFailed()) throw new Error(`Failed to unzip all APKs!\n${unzipProgress.prettyErrors()}`); if (unzipProgress.anyFailed()) throw new Error(`Failed to unzip all APKs!\n${unzipProgress.prettyErrors()}`);
await Bun.write(join(workFolder, "ver"), version); await Bun.write(join(channel.workFolder, "ver"), channel.version);
} else log("Reusing folder!"); } else log("Reusing folder!");
}
// Run tasks async function resetRepos() {
for (const ch of channels) await rm(join("../data", `.update-ok-${ch}`), { force: true }).catch(() => {});
for (const cwd of ["../data", "../canvas"] as const) {
await Bun.$`git reset --hard`.cwd(cwd).nothrow().quiet().then(handleShellErr);
await Bun.$`git restore --staged .`
.cwd(cwd)
.nothrow()
.quiet()
.catch(() => {});
}
}
await runTasks(); async function processChannel(channelName: Channel, targetVersion: string) {
const channel = setChannel(channelName, targetVersion);
log(`\n${"═".repeat(30)}\n${channel.channel}: ${channel.version}\n${"═".repeat(30)}`);
log("✅ Done"); try {
await downloadApks(channel);
await runTasks(channel);
await Bun.write(join("../data", `.update-ok-${channel.channel}`), channel.version);
} finally {
await rm(channel.workFolder, { force: true, recursive: true }).catch(() => {});
}
}
if (!isMock) {
await resetRepos();
for (const channelName of channels) {
const targetVersion = latest[channelName].toString();
const local = await Bun.file(join("../data", channelName, "version.txt"))
.text()
.catch(() => "");
if (!force && local === targetVersion) {
log(`${channelName}: up to date (${targetVersion})`);
continue;
}
await processChannel(channelName, targetVersion);
}
} else {
for (const channelName of channels) {
const version =
(await Bun.file(join("../data", channelName, "version.txt"))
.text()
.catch(() => "0")) || "0";
await runTasks(setChannel(channelName, version));
}
}
log("\n✅ Done");

View file

@ -1,20 +1,20 @@
import mock from "../mock"; import mock from "../mock";
import type { Differs } from "../types"; import type { Differs } from "../types";
import type { ChannelContext } from "./channel";
import { commit } from "./git"; import { commit } from "./git";
import { log, makeProgress, wrapPromise } from "./progress"; import { log, makeProgress, wrapPromise } from "./progress";
import { apksFolder, codePath, cuteVersion, isMock, isQuiet, oprevFiles, prevFiles, version } from "./shared"; import { isMock, isQuiet, oprevFiles, prevFiles } from "./shared";
import codeTask from "./tasks/code"; 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 { formatError, handleShellErr, join } from "./utils"; import { formatError, join } from "./utils";
export async function runTasks() { export async function runTasks(channel: ChannelContext) {
log("\nRunning tasks..."); log(`\nRunning tasks for ${channel.cuteVersion}...`);
const progress = makeProgress( const progress = makeProgress(
{ {
preinit: "Preinit", preinit: "Preinit",
preinit_discard: "Discarding changes",
preinit_save: "Caching original files", preinit_save: "Caching original files",
decompile: "Decompilation", decompile: "Decompilation",
decompile_downloading: "Downloading decompiler", decompile_downloading: "Downloading decompiler",
@ -46,34 +46,10 @@ export async function runTasks() {
try { try {
progress.start("preinit"); progress.start("preinit");
progress.start("preinit_discard");
await Bun.$`git clean -f -- .update-skipped`
.cwd("../data")
.nothrow()
.quiet()
.catch(() => {});
await Bun.$`git reset --hard`.cwd("../data").nothrow().quiet().then(handleShellErr);
await Bun.write("../data/version.txt", version);
// canvas branch may not exist in test
await Bun.$`git reset --hard`
.cwd("../canvas")
.nothrow()
.quiet()
.then(() => {});
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("../canvas")
.nothrow()
.quiet()
.then(() => {});
progress.update("preinit_discard", true);
progress.start("preinit_save"); progress.start("preinit_save");
prevFiles.clear();
for (const oprev of oprevFiles) { for (const oprev of oprevFiles) {
for (const base of ["../data", "../canvas"] as const) { for (const base of [channel.dataDir, channel.canvasDir] as const) {
const file = Bun.file(join(base, oprev)); const file = Bun.file(join(base, oprev));
if (await file.exists()) { if (await file.exists()) {
prevFiles.set(oprev, await file.arrayBuffer()); prevFiles.set(oprev, await file.arrayBuffer());
@ -85,12 +61,12 @@ export async function runTasks() {
progress.update("preinit", true); progress.update("preinit", true);
} catch (e) { } catch (e) {
progress.update("preinit", false); progress.update("preinit", false);
throw new Error(`Failed to discard changes!\n${e}`); throw new Error(`Failed to cache original files!\n${e}`);
} }
try { try {
gzipDone = (await wrapPromise( gzipDone = (await wrapPromise(
decompile(progress, join(apksFolder, "base", "assets", "index.android.bundle")), decompile(channel, progress, join(channel.apksFolder, "base", "assets", "index.android.bundle")),
progress, progress,
"decompile", "decompile",
)) as Promise<void> | undefined; )) as Promise<void> | undefined;
@ -98,30 +74,20 @@ export async function runTasks() {
const detail = (formatError(e) || String(e)).split("\n")[0]; const detail = (formatError(e) || String(e)).split("\n")[0];
console.warn( console.warn(
`\nDecompile failed or timed out (${detail}).` + `\nDecompile failed or timed out (${detail}).` +
`\nSkipping update for this release so the next run can retry.\n`, `\nSkipping ${channel.channel} for this release so the next run can retry.\n`,
); );
// restore version.txt so the next check sees the release as still pending and re-runs
await Bun.$`git checkout -- version.txt`
.cwd("../data")
.nothrow()
.quiet()
.catch(() => {});
await Bun.$`git checkout -- version.txt`
.cwd("../canvas")
.nothrow()
.quiet()
.catch(() => {});
// mark the run as skipped so the workflow won't publish/tag a release that never decompiled
await Bun.write("../data/.update-skipped", version).catch(() => {});
return; return;
} }
const code = (await Bun.file(codePath).text()).replace(/\r/g, "").split("\n"); await Bun.write(join(channel.dataDir, "version.txt"), channel.version);
await Bun.write(join(channel.canvasDir, "version.txt"), channel.version).catch(() => {});
await wrapPromise(codeTask(progress, code), progress, "code"); const code = (await Bun.file(channel.codePath).text()).replace(/\r/g, "").split("\n");
await wrapPromise(codeTask(channel, progress, code), progress, "code");
if (progress.someFailed("code")) throw new Error(`Failed at parser tasks!\n${progress.prettyErrors("code")}`); if (progress.someFailed("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(channel, code), progress, "colors").catch((e) => {
progress.update("colors", false, String(e)); progress.update("colors", false, String(e));
console.warn("Colors task failed (non-critical):", e); console.warn("Colors task failed (non-critical):", e);
}); });
@ -147,7 +113,7 @@ export async function runTasks() {
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 {
const result = await diffs(progress); const result = await diffs(channel, progress);
if (result) { if (result) {
differs = result; differs = result;
progress.update("diff", true); progress.update("diff", true);
@ -171,9 +137,9 @@ export async function runTasks() {
progress.update("webhook", null); progress.update("webhook", null);
} }
await commit(["version.txt"], `chore: bump app version to ${cuteVersion}`); await commit(["version.txt"], `chore: bump app version to ${channel.cuteVersion}`, channel.dataDir);
// canvas version bump (if canvas checkout exists) // canvas version bump (if canvas checkout exists)
try { try {
await commit(["version.txt"], `chore: bump app version to ${cuteVersion}`, "../canvas"); await commit(["version.txt"], `chore: bump app version to ${channel.cuteVersion}`, channel.canvasDir);
} catch {} } catch {}
} }

View file

@ -1,21 +1,12 @@
import { join } from "node:path";
export const trackerUserAgent = "Themelings <https://github.com/nexpid/Themelings/>"; export const trackerUserAgent = "Themelings <https://github.com/nexpid/Themelings/>";
export const maxDiffChanges = 20; export const maxDiffChanges = 20;
export const workFolder = "tmp";
export const apksFolder = join(workFolder, "apks");
export const codePath = join(workFolder, "code.js");
export const modulesPath = join(workFolder, "modules");
export const modulePathsDest = join("..", "data", "module-paths.json");
export const apkSplits = ["base", "config.hdpi", "config.xxhdpi"] as const; export const apkSplits = ["base", "config.hdpi", "config.xxhdpi"] as const;
export const oprevFiles = ["source.jsonl", "raw.json", "semantic.json", "semantic_simple.json", "icons.json"] as const; export const oprevFiles = ["source.jsonl", "raw.json", "semantic.json", "semantic_simple.json", "icons.json"] as const;
export const prevFiles = new Map<(typeof oprevFiles)[number], ArrayBuffer>(); export const prevFiles = new Map<(typeof oprevFiles)[number], ArrayBuffer>();
export const version = await Bun.file("../data/version.txt").text(); export const force = process.argv.includes("--force");
export const cuteVersion = `${["stable", "beta", "alpha"][Number(version[3])] ?? "version"} ${version.slice(0, 3)}.${Number(version.slice(-2))}`;
export const isMock = process.argv.includes("--mock"); export const isMock = process.argv.includes("--mock");
export const diffAnyway = process.argv.includes("--diff-anyway"); export const diffAnyway = process.argv.includes("--diff-anyway");
export const commitAnyway = process.argv.includes("--commit-anyway"); export const commitAnyway = process.argv.includes("--commit-anyway");

View file

@ -1,8 +1,9 @@
import { mkdir, readdir, rm, 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 type { ChannelContext } from "../channel";
import { commit } from "../git"; import { commit } from "../git";
import type { Progress } from "../progress"; import type { Progress } from "../progress";
import { commitAnyway, cuteVersion, modulePathsDest, modulesPath } from "../shared"; import { commitAnyway } from "../shared";
import { formatBytes, join, sortEntries } from "../utils"; import { formatBytes, join, sortEntries } from "../utils";
const MAX_FILE_SIZE = 100 * 1024 * 1024; const MAX_FILE_SIZE = 100 * 1024 * 1024;
@ -21,16 +22,16 @@ function sanitizePath(p: string): string {
.replace(/[<>:"|?*]/g, "_"); .replace(/[<>:"|?*]/g, "_");
} }
export default async function code(progress: Progress, _code: string[]) { export default async function code(channel: ChannelContext, progress: Progress, _code: string[]) {
progress.start("code_getting"); progress.start("code_getting");
if (!(await stat(modulesPath).catch(() => false))) { if (!(await stat(channel.modulesPath).catch(() => false))) {
throw new Error(`Modules directory not found at ${modulesPath}`); throw new Error(`Modules directory not found at ${channel.modulesPath}`);
} }
const modulePaths = new Map<number, string>(); const modulePaths = new Map<number, string>();
try { try {
const arr: { id: number; path: string }[] = await Bun.file(modulePathsDest).json(); const arr: { id: number; path: string }[] = await Bun.file(channel.modulePathsDest).json();
for (const { id, path } of arr) modulePaths.set(id, path); for (const { id, path } of arr) modulePaths.set(id, path);
console.log(`Loaded ${modulePaths.size} module path mappings`); console.log(`Loaded ${modulePaths.size} module path mappings`);
} catch { } catch {
@ -54,7 +55,7 @@ export default async function code(progress: Progress, _code: string[]) {
return files; return files;
} }
const rawFiles = await scanDir(modulesPath, modulesPath); const rawFiles = await scanDir(channel.modulesPath, channel.modulesPath);
function classifyUnmapped(relativePath: string): string { function classifyUnmapped(relativePath: string): string {
if (/^module_\d+\.js$/.test(relativePath)) return "__polyfill"; if (/^module_\d+\.js$/.test(relativePath)) return "__polyfill";
@ -88,7 +89,7 @@ export default async function code(progress: Progress, _code: string[]) {
} }
await Bun.write( await Bun.write(
"../data/source.jsonl", join(channel.dataDir, "source.jsonl"),
sortEntries([...files.entries()]) sortEntries([...files.entries()])
.map(([file, size]) => `{ "file": ${JSON.stringify(file)}, "size": ${size} }`) .map(([file, size]) => `{ "file": ${JSON.stringify(file)}, "size": ${size} }`)
.join("\n"), .join("\n"),
@ -99,7 +100,7 @@ export default async function code(progress: Progress, _code: string[]) {
if (process.env.NODE_ENV !== "test" && !commitAnyway) { if (process.env.NODE_ENV !== "test" && !commitAnyway) {
progress.start("code_remaking"); progress.start("code_remaking");
const filePrefix = "../data/source"; const filePrefix = join(channel.dataDir, "source");
await rm(filePrefix, { recursive: true, force: true }); await rm(filePrefix, { recursive: true, force: true });
await Promise.all( await Promise.all(
@ -107,7 +108,7 @@ export default async function code(progress: Progress, _code: string[]) {
if (fileSize > MAX_FILE_SIZE) return; if (fileSize > MAX_FILE_SIZE) return;
const dest = join(filePrefix, resolveDest(relativePath)); const dest = join(filePrefix, resolveDest(relativePath));
await mkdir(pathJoin(dest, ".."), { recursive: true }); await mkdir(pathJoin(dest, ".."), { recursive: true });
await Bun.write(dest, Bun.file(join(modulesPath, relativePath))); await Bun.write(dest, Bun.file(join(channel.modulesPath, relativePath)));
}), }),
); );
@ -119,9 +120,9 @@ export default async function code(progress: Progress, _code: string[]) {
if (skipped.length) console.warn(`Skipped ${skipped.length} file(s) >100MB (GitHub limit):\n${skipped.join("\n")}`); if (skipped.length) console.warn(`Skipped ${skipped.length} file(s) >100MB (GitHub limit):\n${skipped.join("\n")}`);
const message = skipped.length const message = skipped.length
? `chore: update source for ${cuteVersion}\n\nSkipped ${skipped.length} file(s) >100MB (GitHub limit): ${skipped.join(", ")}` ? `chore: update source for ${channel.cuteVersion}\n\nSkipped ${skipped.length} file(s) >100MB (GitHub limit): ${skipped.join(", ")}`
: `chore: update source for ${cuteVersion}`; : `chore: update source for ${channel.cuteVersion}`;
await commit(["source.jsonl", "source/*"], message); await commit(["source.jsonl", "source/*"], message, channel.dataDir);
progress.update("code_pushing", true); progress.update("code_pushing", true);
} else { } else {
progress.update("code_remaking", null); progress.update("code_remaking", null);

View file

@ -1,9 +1,9 @@
import { runInNewContext } from "node:vm"; import { runInNewContext } from "node:vm";
import Color, { type ColorInstance } from "color"; import Color, { type ColorInstance } from "color";
import type { RawColors, SemanticColors } from "../../types"; import type { RawColors, SemanticColors } from "../../types";
import type { ChannelContext } from "../channel";
import { commit } from "../git"; import { commit } from "../git";
import { cuteVersion } from "../shared"; import { join, sortObj } from "../utils";
import { sortObj } from "../utils";
function hex(color: ColorInstance) { function hex(color: ColorInstance) {
return (color.alpha() === 1 ? color.hex() : color.hexa()).toLowerCase(); return (color.alpha() === 1 ? color.hex() : color.hexa()).toLowerCase();
@ -97,17 +97,20 @@ export function convertSimpleSemantic(semantic: SemanticColors) {
return simpleSemantic; return simpleSemantic;
} }
export default async function colors(code: string[]) { export default async function colors(channel: ChannelContext, code: string[]) {
const raw = getInternalRawColors(code); const raw = getInternalRawColors(code);
await Bun.write("../canvas/raw.json", JSON.stringify(sortObj(raw), null, 4)); await Bun.write(join(channel.canvasDir, "raw.json"), JSON.stringify(sortObj(raw), null, 4));
const semantic = getInternalSemanticColors(code, raw); const semantic = getInternalSemanticColors(code, raw);
await Bun.write("../canvas/semantic.json", JSON.stringify(sortObj(semantic), null, 4)); await Bun.write(join(channel.canvasDir, "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(
join(channel.canvasDir, "semantic_simple.json"),
JSON.stringify(sortObj(convertSimpleSemantic(semantic)), null, 4),
);
await commit( await commit(
["raw.json", "semantic.json", "semantic_simple.json"], ["raw.json", "semantic.json", "semantic_simple.json"],
`chore: update colors for ${cuteVersion}`, `chore: update colors for ${channel.cuteVersion}`,
"../canvas", channel.canvasDir,
); );
} }

View file

@ -1,12 +1,11 @@
import { exists } from "node:fs/promises"; import { exists } from "node:fs/promises";
import { spawn } from "bun"; import { spawn } from "bun";
import type { ChannelContext } from "../channel";
import { commit } from "../git"; import { commit } from "../git";
import type { Progress } from "../progress"; import type { Progress } from "../progress";
import { codePath, commitAnyway, cuteVersion, modulePathsDest, modulesPath, workFolder } from "../shared"; import { commitAnyway } from "../shared";
import { handleShellErr, join } from "../utils"; import { handleShellErr, join } from "../utils";
const bundleDest = join("..", "data", "index.android.bundle");
const gzipWorkerURL = new URL("decompile-gzip.ts", import.meta.url).href; const gzipWorkerURL = new URL("decompile-gzip.ts", import.meta.url).href;
// generous cap so a hanging decompiler is aborted deterministically instead of wedging the job // generous cap so a hanging decompiler is aborted deterministically instead of wedging the job
@ -26,8 +25,9 @@ async function runDecompiler(cmd: string[], label: string) {
if (exitCode !== 0 && exitCode !== 11) throw new Error(`${label} failed (exit ${exitCode})\n${out}`.trim()); if (exitCode !== 0 && exitCode !== 11) throw new Error(`${label} failed (exit ${exitCode})\n${out}`.trim());
} }
export default async function decompile(progress: Progress, pathToBundle: string) { export default async function decompile(channel: ChannelContext, progress: Progress, pathToBundle: string) {
const pathToDecompiler = join(workFolder, "decompiler"); // shared across all channels within a run (built once)
const pathToDecompiler = join("tmp", "decompiler");
progress.start("decompile_downloading"); progress.start("decompile_downloading");
if (!(await exists(pathToDecompiler))) { if (!(await exists(pathToDecompiler))) {
@ -47,18 +47,18 @@ export default async function decompile(progress: Progress, pathToBundle: string
await Bun.$`cargo build --release -p hbc-decomp-cli`.cwd(pathToDecompiler).quiet().nothrow().then(handleShellErr); await Bun.$`cargo build --release -p hbc-decomp-cli`.cwd(pathToDecompiler).quiet().nothrow().then(handleShellErr);
} }
if (!(await Bun.file(codePath).exists())) { if (!(await Bun.file(channel.codePath).exists())) {
await runDecompiler([decompilerBin, "decompile", pathToBundle, "--output", codePath], "decompile"); await runDecompiler([decompilerBin, "decompile", pathToBundle, "--output", channel.codePath], "decompile");
} }
if (!(await exists(modulesPath))) { if (!(await exists(channel.modulesPath))) {
await runDecompiler([decompilerBin, "extract", pathToBundle, "--output", modulesPath], "extract"); await runDecompiler([decompilerBin, "extract", pathToBundle, "--output", channel.modulesPath], "extract");
} }
progress.update("decompile_decompiling", true); progress.update("decompile_decompiling", true);
progress.start("decompile_paths"); progress.start("decompile_paths");
const codeJs = await Bun.file(codePath).text(); const codeJs = await Bun.file(channel.codePath).text();
const modulePaths: { id: number; path: string }[] = []; const modulePaths: { id: number; path: string }[] = [];
const moduleSections = codeJs.split(/\/\/ === Module (\d+): .+ ===\n?/); const moduleSections = codeJs.split(/\/\/ === Module (\d+): .+ ===\n?/);
for (let i = 1; i < moduleSections.length; i += 2) { for (let i = 1; i < moduleSections.length; i += 2) {
@ -67,13 +67,14 @@ export default async function decompile(progress: Progress, pathToBundle: string
const m = content.match(/fileFinishedImporting\("([^"]+)"\)/); const m = content.match(/fileFinishedImporting\("([^"]+)"\)/);
if (m) modulePaths.push({ id: modId, path: m[1] }); if (m) modulePaths.push({ id: modId, path: m[1] });
} }
await Bun.write(modulePathsDest, JSON.stringify(modulePaths)); await Bun.write(channel.modulePathsDest, JSON.stringify(modulePaths));
console.log(`Extracted ${modulePaths.length} module paths`); console.log(`Extracted ${modulePaths.length} module paths`);
progress.update("decompile_paths", true); progress.update("decompile_paths", true);
if (process.env.NODE_ENV !== "test" && !commitAnyway) { if (process.env.NODE_ENV !== "test" && !commitAnyway) {
const gzFile = "code.js.gz"; const gzFile = "code.js.gz";
const bundleDest = join("..", "data", channel.channel, "index.android.bundle");
await Bun.write(bundleDest, Bun.file(pathToBundle)); await Bun.write(bundleDest, Bun.file(pathToBundle));
const gzipper = new Worker(gzipWorkerURL); const gzipper = new Worker(gzipWorkerURL);
@ -83,14 +84,15 @@ export default async function decompile(progress: Progress, pathToBundle: string
if (data === true) { if (data === true) {
await commit( await commit(
[gzFile, "module-paths.json", "index.android.bundle"], [gzFile, "module-paths.json", "index.android.bundle"],
`chore: update decompiled code for ${cuteVersion}`, `chore: update decompiled code for ${channel.cuteVersion}`,
channel.dataDir,
); );
progress.update("decompile_gzip", true); progress.update("decompile_gzip", true);
} }
gzipper.terminate(); gzipper.terminate();
resolveGzip(); resolveGzip();
}); });
gzipper.postMessage({ path: codePath, target: join("../data", gzFile) }); gzipper.postMessage({ path: channel.codePath, target: join("../data", channel.channel, gzFile) });
return gzipDone; return gzipDone;
} }
progress.update("decompile_gzip", null); progress.update("decompile_gzip", null);

View file

@ -7,6 +7,7 @@ import {
type RawColors, type RawColors,
type SemanticColors, type SemanticColors,
} from "../../types"; } from "../../types";
import type { ChannelContext } from "../channel";
import { fetchGitChanged, gitChanged } from "../git"; import { fetchGitChanged, gitChanged } from "../git";
import { type Progress, wrapPromise } from "../progress"; import { type Progress, wrapPromise } from "../progress";
import { diffAnyway, prevFiles } from "../shared"; import { diffAnyway, prevFiles } from "../shared";
@ -100,11 +101,11 @@ function diffEntriesCode(
return changes; return changes;
} }
async function diffRaw() { async function diffRaw(channel: ChannelContext) {
if (!prevFiles.has("raw.json")) throw new Error("Missing prevFile: raw.json"); 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 oldRaw = JSON.parse(new TextDecoder().decode(prevFiles.get("raw.json"))) as RawColors;
const newRaw: RawColors = await Bun.file(join("../canvas", "raw.json")).json(); const newRaw: RawColors = await Bun.file(join(channel.canvasDir, "raw.json")).json();
return diffEntries(oldRaw, newRaw, { return diffEntries(oldRaw, newRaw, {
getSource: (_, val) => val, getSource: (_, val) => val,
@ -122,11 +123,11 @@ function getSemanticLabel(semantic: Record<string, [string, unknown]>) {
.join(", "); .join(", ");
} }
async function diffSemantic() { async function diffSemantic(channel: ChannelContext) {
if (!prevFiles.has("semantic.json")) throw new Error("Missing prevFile: semantic.json"); if (!prevFiles.has("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(channel.canvasDir, "semantic.json")).json();
const changes = diffEntries(oldSemantic, newSemantic, { const changes = diffEntries(oldSemantic, newSemantic, {
getSource: (_, val) => getSource: (_, val) =>
@ -173,13 +174,16 @@ async function diffSemantic() {
return changes; return changes;
} }
async function diffIcons() { async function diffIcons(channel: ChannelContext) {
if (!prevFiles.has("icons.json")) throw new Error("Missing prevFile: icons.json"); if (!prevFiles.has("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(channel.canvasDir, "icons.json")).json();
const iconDir = { old: join("../canvas", "oldicons"), new: join("../canvas", "icons") }; const iconDir = {
old: join(channel.canvasDir, "oldicons"),
new: join(channel.canvasDir, "icons"),
};
return diffEntries(oldIcons, newIcons, { return diffEntries(oldIcons, newIcons, {
getSource: (_, val) => join(iconDir.new, val.file), getSource: (_, val) => join(iconDir.new, val.file),
@ -198,19 +202,23 @@ function parseSource(text: string) {
) as Record<string, { size: number; compSize: number }>; ) as Record<string, { size: number; compSize: number }>;
} }
async function diffCode() { async function diffCode(channel: ChannelContext) {
if (!prevFiles.has("source.jsonl")) throw new Error("Missing prevFile: source.jsonl"); if (!prevFiles.has("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(channel.dataDir, "source.jsonl")).text());
return diffEntriesCode(oldCode, newCode); return diffEntriesCode(oldCode, newCode);
} }
export default async function diffs(progress: Progress) { export default async function diffs(channel: ChannelContext, progress: Progress) {
await fetchGitChanged(); await fetchGitChanged();
if (!(gitChanged.has("version.txt") || diffAnyway)) { const prefix = `${channel.channel}/`;
const changed = (name: string) => gitChanged.has(prefix + name);
const hasBaseline = (name: string) => prevFiles.has(name as typeof prevFiles extends Map<infer K, any> ? K : never);
if (!(changed("version.txt") || diffAnyway)) {
progress.update("diff", null); progress.update("diff", null);
progress.update("diff_raw", null); progress.update("diff_raw", null);
progress.update("diff_semantic", null); progress.update("diff_semantic", null);
@ -222,30 +230,30 @@ export default async function diffs(progress: Progress) {
const differs: Differs = { raw: new Map(), semantic: new Map(), icons: new Map(), code: new Map() }; 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") changed("raw.json") && hasBaseline("raw.json")
? wrapPromise( ? wrapPromise(
diffRaw().then((x) => (differs.raw = x)), diffRaw(channel).then((x) => (differs.raw = x)),
progress, progress,
"diff_raw", "diff_raw",
) )
: progress.update("diff_raw", null), : progress.update("diff_raw", null),
gitChanged.has("semantic.json") changed("semantic.json") && hasBaseline("semantic.json")
? wrapPromise( ? wrapPromise(
diffSemantic().then((x) => (differs.semantic = x)), diffSemantic(channel).then((x) => (differs.semantic = x)),
progress, progress,
"diff_semantic", "diff_semantic",
) )
: progress.update("diff_semantic", null), : progress.update("diff_semantic", null),
gitChanged.has("icons.json") changed("icons.json") && hasBaseline("icons.json")
? wrapPromise( ? wrapPromise(
diffIcons().then((x) => (differs.icons = x)), diffIcons(channel).then((x) => (differs.icons = x)),
progress, progress,
"diff_icons", "diff_icons",
) )
: progress.update("diff_icons", null), : progress.update("diff_icons", null),
gitChanged.has("source.jsonl") changed("source.jsonl") && hasBaseline("source.jsonl")
? wrapPromise( ? wrapPromise(
diffCode().then((x) => (differs.code = x)), diffCode(channel).then((x) => (differs.code = x)),
progress, progress,
"diff_code", "diff_code",
) )

View file

@ -2,20 +2,16 @@ import { copyFile, mkdir, readdir, rename } from "node:fs/promises";
import { basename } from "node:path"; import { basename } from "node:path";
import { runInNewContext } from "node:vm"; import { runInNewContext } from "node:vm";
import type { Icons } from "../../types"; import type { Icons } from "../../types";
import type { ChannelContext } from "../channel";
import { commit } from "../git"; import { commit } from "../git";
import type { Progress } from "../progress"; import type { Progress } from "../progress";
import { apkSplits, apksFolder, cuteVersion } from "../shared"; import { apkSplits } from "../shared";
import { discordPath, join, listRequiredDirs, sortObj } from "../utils"; import { discordPath, join, listRequiredDirs, sortObj } from "../utils";
const infoObjRegex = /({.+?})/; const infoObjRegex = /({.+?})/;
const scalesArrayRegex = /(\[.+?\])/; const scalesArrayRegex = /(\[.+?\])/;
// canvas branch stores icons export async function parseAssets(channel: ChannelContext, code: string[]) {
const iconsJsonPath = "../canvas/icons.json";
const iconsDir = "../canvas/icons";
const oldIconsDir = "../canvas/oldicons";
export async function parseAssets(code: string[]) {
const retrievedAssets: { const retrievedAssets: {
httpServerLocation: string; httpServerLocation: string;
width: number; width: number;
@ -58,10 +54,12 @@ export async function parseAssets(code: string[]) {
const apkPaths = new Map<string, string>(); const apkPaths = new Map<string, string>();
for (const split of apkSplits) { for (const split of apkSplits) {
const folder = join(apksFolder, split); const folder = join(channel.apksFolder, split);
for (const path of await readdir(folder, { recursive: true })) apkPaths.set(basename(path), join(folder, path)); for (const path of await readdir(folder, { recursive: true })) apkPaths.set(basename(path), join(folder, path));
} }
const iconsDir = join(channel.canvasDir, "icons");
const icons: Icons = {}; const icons: Icons = {};
const toCopy: { from: string; to: string }[] = []; const toCopy: { from: string; to: string }[] = [];
for (const asset of retrievedAssets) { for (const asset of retrievedAssets) {
@ -95,21 +93,23 @@ export async function parseAssets(code: string[]) {
return { icons, toCopy }; return { icons, toCopy };
} }
export default async function icons(progress: Progress, code: string[]) { export default async function icons(channel: ChannelContext, progress: Progress, code: string[]) {
progress.start("icons_getting"); progress.start("icons_getting");
const { icons, toCopy } = await parseAssets(code); const { icons, toCopy } = await parseAssets(channel, code);
await Bun.write(iconsJsonPath, JSON.stringify(sortObj(icons), undefined, 4)); await Bun.write(join(channel.canvasDir, "icons.json"), JSON.stringify(sortObj(icons), undefined, 4));
progress.update("icons_getting", true); progress.update("icons_getting", true);
progress.start("icons_copying"); progress.start("icons_copying");
const iconsDir = join(channel.canvasDir, "icons");
const oldIconsDir = join(channel.canvasDir, "oldicons");
await rename(iconsDir, oldIconsDir).catch(() => {}); await rename(iconsDir, oldIconsDir).catch(() => {});
const dirs = listRequiredDirs(toCopy.map((x) => x.to)); const dirs = listRequiredDirs(toCopy.map((x) => x.to));
await Promise.all(dirs.map((dir) => mkdir(dir, { recursive: true }))); await Promise.all(dirs.map((dir) => mkdir(dir, { recursive: true })));
await Promise.all(toCopy.map(({ from, to }) => copyFile(from, to))); await Promise.all(toCopy.map(({ from, to }) => copyFile(from, to)));
await commit(["icons.json", "icons"], `chore: update icons for ${cuteVersion}`, "../canvas"); await commit(["icons.json", "icons"], `chore: update icons for ${channel.cuteVersion}`, channel.canvasDir);
progress.update("icons_copying", true); progress.update("icons_copying", true);
} }

View file

@ -11,7 +11,8 @@ import {
import { drawSections } from "../../canvas"; 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 { ctx } from "../channel";
import { maxDiffChanges } from "../shared";
import { formatBytes, sortEntries } from "../utils"; import { formatBytes, sortEntries } from "../utils";
function fileBase(path: string, other?: string) { function fileBase(path: string, other?: string) {
@ -143,7 +144,7 @@ async function sendWebhook(webhook: string, role: string, embeds: WebhookEmbed[]
components: [ components: [
{ {
type: ComponentType.TextDisplay, type: ComponentType.TextDisplay,
content: `## ${title}\n-# ${version} (${cuteVersion})`, content: `## ${title}\n-# ${ctx().version} (${ctx().cuteVersion})`,
}, },
...body.embed.map((content) => ({ ...body.embed.map((content) => ({
type: ComponentType.TextDisplay, type: ComponentType.TextDisplay,