commit 81c5563e3dfc28021a64b9dc98cae20efa72818a Author: user Date: Mon Jul 20 11:40:29 2026 -0300 initial: clone themelings diff --git a/.env.TEMPLATE b/.env.TEMPLATE new file mode 100644 index 00000000000..e07d0e013c5 --- /dev/null +++ b/.env.TEMPLATE @@ -0,0 +1,6 @@ +# optiopnal, used for crossposting messages +bot_token="Bot ................." +# these are required +code_webhook="https://discord.com/api/webhooks/.../......" +color_webhook="https://discord.com/api/webhooks/.../......" +icons_webhook="https://discord.com/api/webhooks/.../......" \ No newline at end of file diff --git a/.github/workflows/update.yml b/.github/workflows/update.yml new file mode 100644 index 00000000000..4f09a66789b --- /dev/null +++ b/.github/workflows/update.yml @@ -0,0 +1,75 @@ +name: update (hourly) + +on: + workflow_dispatch: + inputs: + force: + description: Force update regardless of "bun run check" + default: false + required: false + type: boolean + quiet: + description: Disable sending any messages + default: false + required: false + type: boolean + schedule: + - cron: "30 * * * *" + +jobs: + check-update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: main + path: main + - uses: actions/checkout@v4 + with: + ref: data + path: data + + - uses: actions/setup-python@v6 + with: + python-version: "3.14.1" + + - uses: oven-sh/setup-bun@v2 + + - working-directory: main + run: bun install --frozen-lockfile + + - name: Setup Git + working-directory: data + run: | + git config user.name github-actions + git 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 + if: ${{ steps.check.outputs.version != 'false' }} + env: + code_webhook: ${{ secrets.CODE_WEBHOOK }} + color_webhook: ${{ secrets.COLOR_WEBHOOK }} + icons_webhook: ${{ secrets.ICONS_WEBHOOK }} + bot_token: ${{ secrets.BOT_TOKEN }} + working-directory: main + run: bun run update:prod ${{ github.event.inputs.quiet == 'true' && '--quiet' || '' }} + + - name: Publish changes + if: ${{ steps.check.outputs.version != 'false' }} + working-directory: data + run: git push + + - name: Tag changes + if: ${{ steps.check.outputs.version != 'false' }} + working-directory: data + run: | + git tag -f ${{ steps.check.outputs.version }} + git push origin -f ${{ steps.check.outputs.version }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000000..4675ad4ba1a --- /dev/null +++ b/.gitignore @@ -0,0 +1,180 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +# Custom +tmp +*.env +temp \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000000..16e8e6664e4 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["biomejs.biome"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000000..eaf2d121048 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,30 @@ +{ + "editor.codeActionsOnSave": { + "source.fixAll.biome": "explicit", + "source.organizeImports.biome": "explicit" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[markdown]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[toml]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[yaml]": { + "editor.defaultFormatter": "biomejs.biome" + } +} diff --git a/README.md b/README.md new file mode 100644 index 00000000000..4723b520e1b --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +
+ + Discord server + +

🎨 Themelings

+
+ +A repo that tracks the [Discord](https://github.com/discord) Android app's code, color and icon changes! + +For a closer look at what's scraped, take a look at the [data](https://github.com/nexpid/Themelings/tree/data) branch + +## Credits + +- [hermes-dec](https://github.com/P1sec/hermes-dec) — tool for decompiling hermes React Native bytecode \ No newline at end of file diff --git a/biome.json b/biome.json new file mode 100644 index 00000000000..e63874eb234 --- /dev/null +++ b/biome.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.11/schema.json", + "formatter": { + "enabled": true, + "indentStyle": "tab", + "lineWidth": 120 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "off" + }, + "complexity": { + "noUselessStringConcat": "error", + "noForEach": "error", + "noUselessUndefined": "error", + "useSimplifiedLogicExpression": "error" + }, + "performance": { + "noDelete": "error", + "noNamespaceImport": "error", + "useTopLevelRegex": "warn" + }, + "style": { + "noUselessElse": "error" + } + } + }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "files": { + "includes": ["**/*.*", "!tmp", "!temp", "!node_modules"] + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 00000000000..c90719572ee --- /dev/null +++ b/bun.lock @@ -0,0 +1,94 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "themelings", + "dependencies": { + "@napi-rs/canvas": "^0.1.97", + "color": "^5.0.3", + "discord-api-types": "^0.38.46", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "^1.3.12", + "@types/color": "^4.2.1", + }, + "peerDependencies": { + "typescript": "^6.0.2", + }, + }, + }, + "trustedDependencies": [ + "@biomejs/biome", + ], + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.4.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.11", "@biomejs/cli-darwin-x64": "2.4.11", "@biomejs/cli-linux-arm64": "2.4.11", "@biomejs/cli-linux-arm64-musl": "2.4.11", "@biomejs/cli-linux-x64": "2.4.11", "@biomejs/cli-linux-x64-musl": "2.4.11", "@biomejs/cli-win32-arm64": "2.4.11", "@biomejs/cli-win32-x64": "2.4.11" }, "bin": { "biome": "bin/biome" } }, "sha512-nWxHX8tf3Opb/qRgZpBbsTOqOodkbrkJ7S+JxJAruxOReaDPPmPuLBAGQ8vigyUgo0QBB+oQltNEAvalLcjggA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wOt+ed+L2dgZanWyL6i29qlXMc088N11optzpo10peayObBaAshbTcxKUchzEMp9QSY8rh5h6VfAFE3WTS1rqg=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-gZ6zR8XmZlExfi/Pz/PffmdpWOQ8Qhy7oBztgkR8/ylSRyLwfRPSadmiVCV8WQ8PoJ2MWUy2fgID9zmtgUUJmw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-avdJaEElXrKceK0va9FkJ4P5ci3N01TGkc6ni3P8l3BElqbOz42Wg2IyX3gbh0ZLEd4HVKEIrmuVu/AMuSeFFA=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-+Sbo1OAmlegtdwqFE8iOxFIWLh1B3OEgsuZfBpyyN/kWuqZ8dx9ZEes6zVnDMo+zRHF2wLynRVhoQmV7ohxl2Q=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.11", "", { "os": "linux", "cpu": "x64" }, "sha512-TagWV0iomp5LnEnxWFg4nQO+e52Fow349vaX0Q/PIcX6Zhk4GGBgp3qqZ8PVkpC+cuehRctMf3+6+FgQ8jCEFQ=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.11", "", { "os": "linux", "cpu": "x64" }, "sha512-bexd2IklK7ZgPhrz6jXzpIL6dEAH9MlJU1xGTrypx+FICxrXUp4CqtwfiuoDKse+UlgAlWtzML3jrMqeEAHEhA=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-RJhaTnY8byzxDt4bDVb7AFPHkPcjOPK3xBip4ZRTrN3TEfyhjLRm3r3mqknqydgVTB74XG8l4jMLwEACEeihVg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.11", "", { "os": "win32", "cpu": "x64" }, "sha512-A8D3JM/00C2KQgUV3oj8Ba15EHEYwebAGCy5Sf9GAjr5Y3+kJIYOiESoqRDeuRZueuMdCsbLZIUqmPhpYXJE9A=="], + + "@napi-rs/canvas": ["@napi-rs/canvas@0.1.97", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.97", "@napi-rs/canvas-darwin-arm64": "0.1.97", "@napi-rs/canvas-darwin-x64": "0.1.97", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", "@napi-rs/canvas-linux-arm64-musl": "0.1.97", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-musl": "0.1.97", "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", "@napi-rs/canvas-win32-x64-msvc": "0.1.97" } }, "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ=="], + + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.97", "", { "os": "android", "cpu": "arm64" }, "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ=="], + + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.97", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw=="], + + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.97", "", { "os": "darwin", "cpu": "x64" }, "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA=="], + + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.97", "", { "os": "linux", "cpu": "arm" }, "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw=="], + + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.97", "", { "os": "linux", "cpu": "arm64" }, "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w=="], + + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.97", "", { "os": "linux", "cpu": "arm64" }, "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ=="], + + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.97", "", { "os": "linux", "cpu": "none" }, "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA=="], + + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.97", "", { "os": "linux", "cpu": "x64" }, "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg=="], + + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.97", "", { "os": "linux", "cpu": "x64" }, "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA=="], + + "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.97", "", { "os": "win32", "cpu": "arm64" }, "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A=="], + + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.97", "", { "os": "win32", "cpu": "x64" }, "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ=="], + + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + + "@types/color": ["@types/color@4.2.1", "", { "dependencies": { "@types/color-convert": "2" } }, "sha512-ResWeDLy1vozIMbD6JLRKuNBbIcIlBkjTIxVHHd5Cqtm77T+ahH3BWE/PWv1OhFd1HAwcn8no4ig2uTaRXpYQQ=="], + + "@types/color-convert": ["@types/color-convert@2.0.3", "", { "dependencies": { "@types/color-name": "*" } }, "sha512-2Q6wzrNiuEvYxVQqhh7sXM2mhIhvZR/Paq4FdsQkOMgWsCIkKvSGj8Le1/XalulrmgOzPMqNa0ix+ePY4hTrfg=="], + + "@types/color-name": ["@types/color-name@1.1.4", "", {}, "sha512-hulKeREDdLFesGQjl96+4aoJSHY5b2GRjagzzcqCfIrWhe5vkCqIvrLbqzBaI1q94Vg8DNJZZqTR5ocdWmWclg=="], + + "@types/node": ["@types/node@20.12.14", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg=="], + + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + + "color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="], + + "color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg=="], + + "color-name": ["color-name@2.1.0", "", {}, "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg=="], + + "color-string": ["color-string@2.1.4", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg=="], + + "discord-api-types": ["discord-api-types@0.38.46", "", {}, "sha512-Ae7NcagMG+FPxwuQxGCPEHmLCKMm8YBMPWEuF5J3L+KWrlH4XGR3UoVo4Ne8bwhhHXbpf+DxDqOeW2jBFupXCQ=="], + + "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + + "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 00000000000..0d331ac7a2b --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[console] +depth = 8 \ No newline at end of file diff --git a/local.env.TEMPLATE b/local.env.TEMPLATE new file mode 100644 index 00000000000..630594017ff --- /dev/null +++ b/local.env.TEMPLATE @@ -0,0 +1,8 @@ +# used in "bun run update:test" and "bun run update:mock" +NODE_ENV="test" +# optiopnal, used for crossposting messages +bot_token="Bot ................." +# all values below are optional, they are overrides for the base .env file +code_webhook="https://discord.com/api/webhooks/.../......" +color_webhook="https://discord.com/api/webhooks/.../......" +icons_webhook="https://discord.com/api/webhooks/.../......" \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 00000000000..73301f77001 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "themelings", + "type": "module", + "scripts": { + "check": "bun run src/check.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:mock": "bun run --env-file local.env src/update/index.ts --mock", + "update:prod": "bun run src/update/index.ts", + "canvas:test": "bun run --watch src/canvas/index.test.ts", + "deminify:test": "bun run src/update/deminify.test.ts", + "colors:test": "bun run src/update/tasks/colors.test.ts", + "icons:test": "bun run src/update/tasks/icons.test.ts", + "format": "biome check --fix ." + }, + "dependencies": { + "@napi-rs/canvas": "^0.1.97", + "color": "^5.0.3", + "discord-api-types": "^0.38.46" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "^1.3.12", + "@types/color": "^4.2.1" + }, + "peerDependencies": { + "typescript": "^6.0.2" + }, + "trustedDependencies": [ + "@biomejs/biome" + ], + "packageManager": "bun@1.3.12" +} diff --git a/src/canvas/factory.ts b/src/canvas/factory.ts new file mode 100644 index 00000000000..112b56b3677 --- /dev/null +++ b/src/canvas/factory.ts @@ -0,0 +1,97 @@ +import { join } from "node:path"; +import { type Image, loadImage } from "@napi-rs/canvas"; +import { type Diff, DiffType } from "../types"; + +export type Item = + | { + type: "color"; + colors: string[][]; + } + | { + type: "image"; + image: Image; + } + | { + type: "blob"; + blob: string; + }; + +export interface Row { + title?: string; + subtitle?: string; + item: Item; +} + +export interface Section { + header: string; + columns: Row[][]; +} + +function placeholderFile(file: string) { + return file.endsWith(".lottie") ? join(import.meta.dir, "placeholders/lottie.png") : file; +} + +const numColumns = 5; +const maxRows = 3; + +async function makeSection(diffs: Map, isFile: boolean, type: DiffType, header: string) { + const filtered = [...diffs.entries()].filter(([, diff]) => diff.type === type); + const section: Section = { + header, + columns: Array.from({ length: numColumns }, () => []), + }; + + let i = 0; + for (const [name, diff] of filtered) { + const columnInd = i++ % numColumns; + + const column = section.columns[columnInd], + nextColumn = section.columns[i % numColumns]; + if (!column) throw new Error(`Couldn't find column of index ${columnInd}`); + + if (!nextColumn || nextColumn.length >= maxRows) { + column.push({ + item: { + type: "blob", + blob: `+${(filtered.length - i + 1).toLocaleString("en-US")} ${header.toLowerCase()}`, + }, + }); + break; + } + + const changed = diff.type === DiffType.Changed; + const item: Item = isFile + ? { type: "image", image: await loadImage(placeholderFile(diff.source)) } + : { type: "color", colors: [changed && diff.oldSource.split(","), diff.source.split(",")].filter((x) => !!x) }; + + column.push( + ...[ + changed && + isFile && { + subtitle: name, + item: { + type: "image", + image: await loadImage(placeholderFile(diff.oldSource)), + } as Item, + }, + { + title: name, + subtitle: diff.type === DiffType.Renamed ? diff.oldName : undefined, + item, + }, + ].filter((x) => !!x), + ); + } + + section.columns = section.columns.filter((x) => x.length); + return section; +} + +export async function makeSections(diffs: Map, isFile = false) { + return [ + await makeSection(diffs, isFile, DiffType.Added, "Added"), + await makeSection(diffs, isFile, DiffType.Changed, "Changed"), + await makeSection(diffs, isFile, DiffType.Renamed, "Renamed"), + await makeSection(diffs, isFile, DiffType.Removed, "Removed"), + ].filter((x) => x.columns[0]); +} diff --git a/src/canvas/fonts/ggmono/normal-400.ttf b/src/canvas/fonts/ggmono/normal-400.ttf new file mode 100644 index 00000000000..3a2aa0cb035 Binary files /dev/null and b/src/canvas/fonts/ggmono/normal-400.ttf differ diff --git a/src/canvas/fonts/ggsans/normal-400.ttf b/src/canvas/fonts/ggsans/normal-400.ttf new file mode 100644 index 00000000000..45eefcd3c69 Binary files /dev/null and b/src/canvas/fonts/ggsans/normal-400.ttf differ diff --git a/src/canvas/fonts/ggsans/semibold-600.ttf b/src/canvas/fonts/ggsans/semibold-600.ttf new file mode 100644 index 00000000000..28e88c38c8e Binary files /dev/null and b/src/canvas/fonts/ggsans/semibold-600.ttf differ diff --git a/src/canvas/index.test.ts b/src/canvas/index.test.ts new file mode 100644 index 00000000000..5e531d247e4 --- /dev/null +++ b/src/canvas/index.test.ts @@ -0,0 +1,12 @@ +import mock from "../mock"; +import { drawSections } from "."; +import { makeSections } from "./factory"; + +await Bun.write( + "temp/files.png", + new Uint8Array(drawSections(await makeSections(mock.icons, true)).toBuffer("image/png")), +); +await Bun.write( + "temp/colors.png", + new Uint8Array(drawSections(await makeSections(mock.semantic)).toBuffer("image/png")), +); diff --git a/src/canvas/index.ts b/src/canvas/index.ts new file mode 100644 index 00000000000..16fa988499d --- /dev/null +++ b/src/canvas/index.ts @@ -0,0 +1,232 @@ +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { createCanvas, GlobalFonts } from "@napi-rs/canvas"; +import { file } from "bun"; +import type { Row, Section } from "./factory"; +import { measureText, transformSvg, wrapText } from "./utils"; + +async function bulkRegister(family: string, path: string) { + for (const item of await readdir(path)) GlobalFonts.registerFromPath(join(path, item), family); +} +await bulkRegister("GG Sans", join(import.meta.dir, "fonts/ggsans")); +await bulkRegister("GG Mono", join(import.meta.dir, "fonts/ggmono")); + +function calcColumnWidth(column: { textWidth: number; itemWidth: number }[]) { + return Math.max(0, ...column.map(({ textWidth, itemWidth }) => Math.max(0, textWidth, itemWidth))); +} + +function calcColumnHeight(column: Row[]) { + const texts = column + .map((row) => Number(!!row.title) + Number(!!row.subtitle) + Number(row.item.type === "blob")) + .reduce((a, b) => a + b, 0); + const items = texts + column.length - 1; + return texts * textSizes.title + column.length * itemSize + items * layout.card.gapY; +} + +function calcSectionHeight(columns: Row[][]) { + return Math.max(0, ...columns.map((column) => calcColumnHeight(column))); +} + +const layout = { + background: { + padding: 24, + gap: 18, + }, + section: { + gap: 8, + }, + card: { + radius: 24, + padding: 12, + gapX: 16, + gapY: 8, + }, + blob: { + radius: 24 - 12, + }, + color: { + radius: 24, + }, +}; + +const textSizes = { + header: 24, + title: 18, + blob: 24, +}; + +const colors = { + background: "#1c1d23", // BACKGROUND_BASE_LOWEST + section: "#26272f", // CARD_BACKGROUND_DEFAULT + blob: "#6c6f7c1f", // BACKGROUND_MOD_MUTED + textNormal: "#c7c8ce", // TEXT_NORMAL + 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`; + + const sects = sections.map(({ header, columns }) => ({ + headerWidth: measureText(mctx, headerFont, header).width, + header, + columns: columns.map((column) => + 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) => + Math.max(0, ...sects.map(({ columns }) => (columns[i] ? calcColumnWidth(columns[i]) : 0))), + ); + const sectionWidth = + columnWidths.reduce((a, b) => a + b, 0) + layout.card.gapX * (columnWidths.length - 1) + layout.card.padding * 2; + + const width = Math.max(0, ...sects.map(({ headerWidth }) => headerWidth), sectionWidth); + const height = + sects + .map( + ({ columns }) => + textSizes.header + + layout.section.gap + + layout.card.padding * 2 + + calcSectionHeight(columns) + + layout.background.gap, + ) + .reduce((a, b) => a + b, 0) - layout.background.gap; + + const canvas = createCanvas(layout.background.padding * 2 + width, layout.background.padding * 2 + height); + const ctx = canvas.getContext("2d"); + ctx.textBaseline = "top"; + + ctx.fillStyle = colors.background; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + let x = layout.background.padding, + y = layout.background.padding; + for (const sect of sects) { + ctx.fillStyle = colors.textNormal; + ctx.font = headerFont; + ctx.fillText(sect.header, x, y); + y += textSizes.header + layout.section.gap; + + const sectionHeight = calcSectionHeight(sect.columns); + ctx.fillStyle = colors.section; + ctx.beginPath(); + ctx.roundRect(x, y, sectionWidth, sectionHeight + layout.card.padding * 2, layout.card.radius); + ctx.fill(); + y += layout.card.padding; + x += layout.card.padding; + + let wx = x; + for (let i = 0; i < sect.columns.length; i++) { + let wy = y; + + const column = sect.columns[i]; + const columnWidth = columnWidths[i]; + if (!columnWidth) throw new Error(`Failed to get columnWidth for ${i}, ${columnWidth}`); + + const centerX = wx + columnWidth / 2; + for (const row of column) { + ctx.font = titleFont; + if (row.subtitle) { + ctx.fillStyle = colors.textMuted; + ctx.fillText(row.subtitle, centerX - row.textWidth / 2, wy); + wy += textSizes.title + layout.card.gapY; + } + if (row.title) { + ctx.fillStyle = colors.textNormal; + ctx.fillText(row.title, centerX - row.textWidth / 2, wy); + wy += textSizes.title + layout.card.gapY; + } + + const midX = centerX - row.itemWidth / 2; + if (row.item.type === "blob") { + const blobHeight = 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") { + ctx.drawImage(row.item.image, midX, wy, row.itemWidth, itemSize); + wy += itemSize + layout.card.gapY; + } else if (row.item.type === "color") { + for (let i = 0; i < row.item.colors.length; i++) { + 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; + } + } + wx += columnWidth + layout.card.gapX; + } + y += sectionHeight; + + x -= layout.card.padding; + y += layout.card.padding; + y += layout.background.gap; + } + + // watermark + ctx.drawImage(watermark, canvas.width - watermark.width - 1, 1); + + return canvas; +} diff --git a/src/canvas/mock/FirstMockIcon.png b/src/canvas/mock/FirstMockIcon.png new file mode 100644 index 00000000000..580037001a0 Binary files /dev/null and b/src/canvas/mock/FirstMockIcon.png differ diff --git a/src/canvas/mock/FourthMockIcon.png b/src/canvas/mock/FourthMockIcon.png new file mode 100644 index 00000000000..4bb486b21c8 Binary files /dev/null and b/src/canvas/mock/FourthMockIcon.png differ diff --git a/src/canvas/mock/LottieMockIcon.lottie b/src/canvas/mock/LottieMockIcon.lottie new file mode 100644 index 00000000000..a2a4fc1216a Binary files /dev/null and b/src/canvas/mock/LottieMockIcon.lottie differ diff --git a/src/canvas/mock/SecondMockIcon.png b/src/canvas/mock/SecondMockIcon.png new file mode 100644 index 00000000000..0d7eab30520 Binary files /dev/null and b/src/canvas/mock/SecondMockIcon.png differ diff --git a/src/canvas/mock/SecondMockIconNew.png b/src/canvas/mock/SecondMockIconNew.png new file mode 100644 index 00000000000..f83d0103c6b Binary files /dev/null and b/src/canvas/mock/SecondMockIconNew.png differ diff --git a/src/canvas/mock/SvgMockIcon.svg b/src/canvas/mock/SvgMockIcon.svg new file mode 100644 index 00000000000..b3a9647b356 --- /dev/null +++ b/src/canvas/mock/SvgMockIcon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/canvas/mock/ThirdMockIcon.png b/src/canvas/mock/ThirdMockIcon.png new file mode 100644 index 00000000000..65718d5454b Binary files /dev/null and b/src/canvas/mock/ThirdMockIcon.png differ diff --git a/src/canvas/placeholders/lottie.png b/src/canvas/placeholders/lottie.png new file mode 100644 index 00000000000..1a69318a9b1 Binary files /dev/null and b/src/canvas/placeholders/lottie.png differ diff --git a/src/canvas/placeholders/watermark.svg b/src/canvas/placeholders/watermark.svg new file mode 100644 index 00000000000..7d336c52399 --- /dev/null +++ b/src/canvas/placeholders/watermark.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/canvas/utils.ts b/src/canvas/utils.ts new file mode 100644 index 00000000000..73d93bbdde0 --- /dev/null +++ b/src/canvas/utils.ts @@ -0,0 +1,45 @@ +import { type CanvasRenderingContext2D, loadImage } from "@napi-rs/canvas"; + +export function measureText(ctx: CanvasRenderingContext2D, font: string, text: string) { + ctx.font = font; + return ctx.measureText(text); +} + +export function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) { + // https://stackoverflow.com/a/16599668 + const words = text.split(" "); + let lines: string[] = [], + line = words[0]; + + for (let i = 1; i < words.length; i++) { + const word = words[i]; + const newText = `${line} ${word}`; + + const width = ctx.measureText(newText).width; + if (width < maxWidth) line = newText; + else { + lines.push(line); + line = word; + } + } + + lines.push(line); + return lines; +} + +export async function transformSvg(svg: string, config: { color?: string; height?: number; width?: number }) { + const text = config.color ? svg.replace(/fill="#[a-z0-9]{3,6}"/gi, `fill="${config.color}"`) : svg; + + const image = await loadImage(Buffer.from(text)); + if (config.width && config.height) { + image.width = config.width; + image.height = config.height; + } else if (config.width) { + image.height = (image.height / image.width) * config.width; + image.width = config.width; + } else if (config.height) { + image.width = (image.width / image.height) * config.height; + image.height = config.height; + } + return image; +} diff --git a/src/check.ts b/src/check.ts new file mode 100644 index 00000000000..b819f30a8d0 --- /dev/null +++ b/src/check.ts @@ -0,0 +1,19 @@ +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"); diff --git a/src/mock.ts b/src/mock.ts new file mode 100644 index 00000000000..a7f1a07a7b6 --- /dev/null +++ b/src/mock.ts @@ -0,0 +1,147 @@ +import Color from "color"; +import { type CodeDiff, type Diff, DiffType } from "./types"; + +const randomColor = () => + `#${Math.floor(Math.random() * 0xffffff) + .toString(16) + .padStart(6, "0")}`; +const randomColors = () => { + const base = Color(randomColor()); + return [ + base.lighten(0.6).hex(), + base.desaturate(0.3).darken(0.5).hex(), + base.darken(0.6).hex(), + base.darken(0.8).hex(), + ].join(","); +}; + +const semNum = 20; + +// mock data for "bun run update:mock" and "bun run canvas:test" +export default { + semantic: new Map([ + ...Array.from({ length: semNum }, (_, i) => [ + `MOCK_SEMANTIC_SAMPLE_${i + 1}`, + { + type: DiffType.Added, + source: randomColors(), + label: "☀️ #aaaaaa, 🌙 #bbbbbb", + } as Diff, + ]), + ...Array.from({ length: semNum }, (_, i) => [ + `MOCK_SEMANTIC_SAMPLE_${i + 1 + semNum}`, + { + type: DiffType.Changed, + source: randomColors(), + label: "☀️ #aaaaaa, 🌙 #bbbbbb", + oldSource: randomColors(), + oldLabel: "☀️ #888888, 🌙 #999999", + } as Diff, + ]), + ...Array.from({ length: semNum }, (_, i) => [ + `MOCK_SEMANTIC_SAMPLE_${i + 1 + semNum * 2}`, + { + type: DiffType.Renamed, + oldName: `mocked_samentic_sample_${i + 1 + semNum * 2}`, + source: randomColors(), + } as Diff, + ]), + ...Array.from({ length: semNum }, (_, i) => [ + `MOCK__SEMANTIC__REMOVED__SAMPLE__${i + 1 + semNum * 3}`, + { + type: DiffType.Removed, + source: randomColors(), + label: "☀️ #aaaaaa, 🌙 #bbbbbb", + } as Diff, + ]), + ] as [string, Diff][]), + semanticBig: new Map( + Array.from({ length: 35 }, (_, i) => [ + `SOME_EXAMPLE_SEMANTIC_${i}`, + { + type: DiffType.Changed, + source: randomColors(), + label: "☀️ #aaaaaa, 🌙 #bbbbbb", + oldSource: randomColors(), + oldLabel: "☀️ #888888, 🌙 #999999", + } as Diff, + ]), + ), + raw: new Map(), + icons: new Map([ + [ + "FirstMockIcon", + { + type: DiffType.Added, + source: "src/canvas/mock/FirstMockIcon.png", + label: "c6f6357f", + } as Diff, + ], + [ + "SvgMockIcon", + { + type: DiffType.Added, + source: "src/canvas/mock/SvgMockIcon.svg", + label: "5d534560", + } as Diff, + ], + [ + "LottieMockIcon", + { + type: DiffType.Added, + source: "src/canvas/mock/LottieMockIcon.lottie", + label: "340efc52", + } as Diff, + ], + [ + "SecondMockIcon", + { + type: DiffType.Changed, + source: "src/canvas/mock/SecondMockIconNew.png", + label: "76ffeecd", + oldSource: "src/canvas/mock/SecondMockIcon.png", + oldLabel: "6e235744", + } as Diff, + ], + [ + "ThirdMockIcon", + { + type: DiffType.Renamed, + oldName: "thirdmkocicon", + source: "src/canvas/mock/ThirdMockIcon.png", + } as Diff, + ], + [ + "FourthMockIcon", + { + type: DiffType.Removed, + source: "src/canvas/mock/FourthMockIcon.png", + label: "cf4e1340", + } as Diff, + ], + ]), + code: new Map([ + [ + "app/foomodule.tsx", + { + type: DiffType.Added, + size: 24782, + } as CodeDiff, + ], + [ + "app/ui/barmodule.ts", + { + type: DiffType.Renamed, + oldName: "app/legacy/ui/bar_module.ts", + size: 5212, + } as CodeDiff, + ], + [ + "app/intl/ReallyBigCoolComponent.tsx", + { + type: DiffType.Removed, + size: 46532, + } as CodeDiff, + ], + ]), +}; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000000..2994c6feaf3 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,56 @@ +export enum DiffType { + Added, + Changed, + Renamed, + Removed, +} + +export type Diff = + | { + type: DiffType.Added | DiffType.Removed; + source: string; + label?: string; + } + | { + type: DiffType.Changed; + source: string; + label?: string; + oldSource: string; + oldLabel?: string; + } + | { + type: DiffType.Renamed; + oldName: string; + source: string; + }; + +export type CodeDiff = + | { + type: DiffType.Added | DiffType.Removed; + size: number; + } + | { + type: DiffType.Renamed; + oldName: string; + size: number; + }; + +export type Differs = Record<"semantic" | "raw" | "icons", Map | undefined> & { + code: Map | undefined; +}; + +export type RawColors = Record; + +export type Icons = Record< + string, + { + file: string; + hash: string; + scales: number[]; + width: number | null; + height: number | null; + } +>; + +export type Semantic = Record; +export type SemanticColors = Record; diff --git a/src/update/deminify.test.ts b/src/update/deminify.test.ts new file mode 100644 index 00000000000..e0e691789b3 --- /dev/null +++ b/src/update/deminify.test.ts @@ -0,0 +1,252 @@ +import { deminify } from "./deminify"; + +const SNIPPET = `r7 = function(a0, a1, a2, a3, a4, a5, a6) { // Environment: r3 + r4 = a1; + r2 = a5; + r5 = a6; + var _closure1_slot0 = r4; + r0 = a2; + var _closure1_slot1 = r0; + var _closure1_slot2 = r5; + r0 = global; + r7 = r0.Object; + r6 = r7.defineProperty; + r1 = {}; + r0 = true; + r1['value'] = r0; + r0 = '__esModule'; + r0 = r6.bind(r7)(r2, r0, r1); + r0 = 0; + r6 = r5[r0]; + r1 = a3; + r0 = undefined; + r1 = r1.bind(r0)(r6); + r1 = 1; + r1 = r5[r1]; + r1 = r4.bind(r0)(r1); + r1 = r1.jsx; + var _closure1_slot3 = r1; + r1 = {}; + r6 = function(a0) { // Original name: openLazy, environment: r3 + _fun32591: for(var _fun32591_ip = 0; ; ) switch(_fun32591_ip) { +case 0: + r3 = a0; + r2 = r3.importer; + r4 = r3.hideActionSheet; + r1 = undefined; + if(!(r4 === r1)) { _fun32591_ip = 25; continue _fun32591 } +case 23: + r4 = true; +case 25: + var _closure2_slot0 = r4; + r3 = r3.isDismissable; + if(!(r3 === r1)) { _fun32591_ip = 41; continue _fun32591 } +case 39: + r3 = false; +case 41: + var _closure2_slot1 = r3; + r2 = r2.bind(r1)(); + r1 = r2.then; + r0 = function(a0) { // Environment: r0 + _fun32592: for(var _fun32592_ip = 0; ; ) switch(_fun32592_ip) { +case 0: + r0 = _closure2_slot0; + if(!r0) { _fun32592_ip = 45; continue _fun32592 } +case 10: + r2 = _closure1_slot1; + r1 = _closure1_slot2; + r0 = 2; + r1 = r1[r0]; + r0 = undefined; + r1 = r2.bind(r0)(r1); + r0 = r1.hideActionSheet; + r0 = r0.bind(r1)(); +case 45: + r2 = _closure1_slot1; + r1 = _closure1_slot2; + r0 = 3; + r1 = r1[r0]; + r0 = undefined; + r3 = r2.bind(r0)(r1); + r2 = r3.dispatch; + r1 = {}; + r5 = 'ALERT_OPEN'; + r1['type'] = r5; + r5 = a0; + r1['alert'] = r5; + r4 = _closure2_slot1; + r1['isDismissable'] = r4; + r1 = r2.bind(r3)(r1); + return r0; + } + }; + r0 = r1.bind(r2)(r0); + return r0; + } + }; + r1['openLazy'] = r6; + r6 = function() { // Original name: close, environment: r3 + r2 = _closure1_slot1; + r1 = _closure1_slot2; + r0 = 3; + r1 = r1[r0]; + r0 = undefined; + r3 = r2.bind(r0)(r1); + r2 = r3.dispatch; + r1 = {}; + r4 = 'ALERT_CLOSE'; + r1['type'] = r4; + r1 = r2.bind(r3)(r1); + return r0; + }; + r1['close'] = r6; + r6 = function(a0) { // Original name: show, environment: r3 + _fun32594: for(var _fun32594_ip = 0; ; ) switch(_fun32594_ip) { +case 0: + r7 = a0; + r3 = this; + r5 = r7.hideActionSheet; + r4 = r7.isDismissable; + r1 = r7.confirmText; + r0 = undefined; + if(!(r1 === r0)) { _fun32594_ip = 90; continue _fun32594 } +case 32: + r10 = _closure1_slot0; + r11 = _closure1_slot2; + r2 = 4; + r8 = r11[r2]; + r8 = r10.bind(r0)(r8); + r9 = r8.intl; + r8 = r9.string; + r2 = r11[r2]; + r2 = r10.bind(r0)(r2); + r2 = r2.t; + r2 = r2.BddRzc; + r1 = r8.bind(r9)(r2); +case 90: + var _closure2_slot0 = r1; + r2 = {'hideActionSheet': 0, 'isDismissable': 0, 'confirmText': 0}; + r13 = null; + r14 = r2; + r1 = silentSetPrototypeOf(r14, r13); + r14 = {}; + r13 = r7; + r12 = r2; + r1 = copyDataProperties(r14, r13, r12); + var _closure2_slot1 = r1; + r2 = r3.openLazy; + r1 = {}; + r6 = function() { // Original name: importer, environment: r6 + r2 = _closure1_slot0; + r0 = _closure1_slot2; + r1 = 6; + r1 = r0[r1]; + r3 = undefined; + r2 = r2.bind(r3)(r1); + r1 = 5; + r1 = r0[r1]; + r0 = r0.paths; + r2 = r2.bind(r3)(r1, r0); + r1 = r2.then; + r0 = function(a0) { // Environment: r0 + r1 = a0; + r1 = r1.default; + var _closure4_slot0 = r1; + r0 = function(a0) { // Environment: r0 + r3 = _closure1_slot3; + r2 = _closure4_slot0; + r1 = {}; + r5 = a0; + r6 = r1; + r0 = copyDataProperties(r6, r5); + r5 = _closure2_slot1; + r6 = r1; + r4 = copyDataProperties(r6, r5); + r4 = _closure2_slot0; + r0 = 'confirmText'; + r1[r0] = r4; + r0 = undefined; + r0 = r3.bind(r0)(r2, r1); + return r0; + }; + return r0; + }; + r0 = r1.bind(r2)(r0); + return r0; + }; + r1['importer'] = r6; + r1['hideActionSheet'] = r5; + r1['isDismissable'] = r4; + r1 = r2.bind(r3)(r1); + return r0; + } + }; + r1['show'] = r6; + r3 = function(a0) { // Original name: confirm, environment: r3 + r1 = this; + var _closure2_slot0 = r1; + r1 = a0; + var _closure2_slot1 = r1; + r1 = global; + r2 = r1.Promise; + r1 = r2.prototype; + r1 = Object.create(r1, {constructor: {value: r2}}); + r3 = function(a0) { // Environment: r0 + r0 = a0; + var _closure3_slot0 = r0; + r3 = _closure2_slot0; + r2 = r3.show; + r1 = {}; + r0 = function() { // Original name: onConfirm, environment: r5 + r2 = _closure3_slot0; + r0 = undefined; + r1 = true; + r1 = r2.bind(r0)(r1); + return r0; + }; + r1['onConfirm'] = r0; + r9 = _closure1_slot0; + r10 = _closure1_slot2; + r6 = 4; + r7 = r10[r6]; + r0 = undefined; + r7 = r9.bind(r0)(r7); + r8 = r7.intl; + r7 = r8.string; + r6 = r10[r6]; + r6 = r9.bind(r0)(r6); + r6 = r6.t; + r6 = r6.ETE/oK; + r6 = r7.bind(r8)(r6); + r1['cancelText'] = r6; + r5 = function() { // Original name: onCancel, environment: r5 + r2 = _closure3_slot0; + r0 = undefined; + r1 = false; + r1 = r2.bind(r0)(r1); + return r0; + }; + r1['onCancel'] = r5; + r11 = _closure2_slot1; + r12 = r1; + r4 = copyDataProperties(r12, r11); + r1 = r2.bind(r3)(r1); + return r0; + }; + r4 = r1; + r0 = new r4[r2](r3, r2); + r0 = r0 instanceof Object ? r0 : r1; + return r0; + }; + r1['confirm'] = r3; + r3 = 7; + r3 = r5[r3]; + r5 = r4.bind(r0)(r3); + r4 = r5.fileFinishedImporting; + r3 = 'actions/native/AlertActionCreators.tsx'; + r3 = r4.bind(r5)(r3); + r2['default'] = r1; + return r0; +};`; + +await Bun.write("temp/deminified.js", deminify(SNIPPET, "actions/native/AlertActionCreators.js").res); diff --git a/src/update/deminify.ts b/src/update/deminify.ts new file mode 100644 index 00000000000..4379439ad7b --- /dev/null +++ b/src/update/deminify.ts @@ -0,0 +1,128 @@ +import { writeFileSync } from "node:fs"; +import { extname } from "node:path"; +import { log } from "./progress"; + +const variableMatch = /\br(\d{1,2})\b/g; +const argumentMatch = /\ba(\d{1,2})\b/g; + +const funMatch = /\b(_fun)(\d+)(_ip)?(?: = (\d+))?\b/g; +const caseMatch = /^case (\d+):/g; + +const functNameMatch = /(function\*?) ?(\(.*?\) {) \/\/ Original name: +\??(.+), environment: \w+$/g; +const functOpenMatch = /function\*? ?\((.*?)\) {/; +const functCloseMatch = /^\s+};/; + +const stringMatch = /(["'`])(?:\\.|[^\\])*?\1/g; +// biome-ignore lint/suspicious/noControlCharactersInRegex: this is needed +const nullstringMatch = /(["'`])\x00(\d+)\x00\1/g; + +const nativeModuleNames = [ + "global", + "require", + "metroImportDefault", + "metroImportAll", + "moduleObject", + "exports", + "dependencyMap", +]; + +export function deminify(code: string, path: string) { + const cleaned = code + .split(" = ") + .slice(1) + .join(" = ") + .slice(0, -1) + .replace(/ \/\/ Environment: \w+$/gm, "") + .split("\n"); + + const lines: string[] = []; + + let depth = 0; + const localArgs = new Map(); + const funMap = new Map(); + const caseMap = new Map(); + caseMap.set("0", 0); + + const varReplacer = (_: string, id: string) => `var${Number(id) + 1}`; + const argReplacer = (_: string, id: string) => { + const n = Number(id), + depth = localArgs.get(n); + if (!depth) log(`WARNING! arg${n} does not have any depth (${path})`); + + if (depth === 1) return nativeModuleNames[n] ?? `native${n + 1}`; + return `arg${n + 1}`; + }; + const funReplacer = (_: string, prefix: string, id: string, suffix: string, caseId?: string) => { + const num = funMap.get(id) ?? String(funMap.size + 1).padStart(4, "0"); + funMap.set(id, num); + + const text = `${prefix}${num}${suffix ?? ""}`; + if (!caseId) return text; + + const caseNum = caseMap.get(caseId) ?? caseMap.size + 1; + caseMap.set(caseId, caseNum); + return `${text} = ${caseNum}`; + }; + const caseReplacer = (_: string, id: string) => { + const num = caseMap.get(id) ?? caseMap.size + 1; + caseMap.set(id, num); + return `case ${num}:`; + }; + const functNameReplacer = (_: string, prefix: string, stuff: string, name: string) => `${prefix} ${name}${stuff}`; + + let j = 1; + for (const raw of cleaned) { + const nullStrings = new Map(); + const line = raw.replace(stringMatch, (match, quot: string) => { + const index = nullStrings.size + 1; + nullStrings.set(String(index), match); + return `${quot}\x00${index}\x00${quot}`; + }); + + // check for start of function + if (functOpenMatch.test(line)) { + const openedArgs = line.match(functOpenMatch)?.[1]?.split(", ")?.length || 0; + + depth++; + for (let i = 0; i < openedArgs; i++) { + localArgs.set(i, depth); + } + } + + // check for end of function + if (functCloseMatch.test(line)) { + for (const arg of localArgs.keys()) { + const n = localArgs.get(arg); + if (n === depth) localArgs.set(arg, n - 1); + } + depth--; + } + + // sanity check + const y = j++; + if (depth < 0) { + writeFileSync("temp/depthcrashed.js", cleaned.join("\n")); + throw new Error(`parsing failed, depth went below 0 (LINE: ${y}, FILE: ${path})`); + } + + const parsed = line + .replace(variableMatch, varReplacer) + .replace(argumentMatch, argReplacer) + .replace(funMatch, funReplacer) + .replace(caseMatch, caseReplacer) + .replace(functNameMatch, functNameReplacer) + .replace(nullstringMatch, (_, __, id: string) => { + const nullString = nullStrings.get(id); + if (nullString) return nullString; + writeFileSync("temp/nullcrashed.js", cleaned.join("\n")); + throw new Error(`parsing failed, nullstring ${id} doesn't exist (LINE: ${y}, FILE: ${path})`); + }); + lines.push(parsed); + } + + const final = lines.join("\n"); + return { + final, + res: `// ${path}\n${extname(path).startsWith(".ts") ? "export default" : "module.exports ="} (${final})();`, + }; +} diff --git a/src/update/git.ts b/src/update/git.ts new file mode 100644 index 00000000000..bb592e2ba6f --- /dev/null +++ b/src/update/git.ts @@ -0,0 +1,51 @@ +import { commitAnyway } from "./shared"; +import { handleShellErr } from "./utils"; + +export const gitChanged = new Set(); + +export async function fetchGitChanged() { + for (const changed of ( + await Bun.$`git status -z -- ':!oldicons' ':!icons' ':!source'`.cwd("../data").quiet().then(handleShellErr) + ) + .text() + .split("\x00") + .filter((x) => x !== "") + .map((x) => x.slice(3))) + gitChanged.add(changed); +} + +const gitQueue: Promise[] = []; + +export async function commit(files: string[], message: string) { + while (gitQueue[0]) { + await gitQueue[0]; + } + + const { promise, resolve } = Promise.withResolvers(); + gitQueue.push(promise); + + if (process.env.NODE_ENV === "test" && !commitAnyway) { + await fetchGitChanged(); + } else { + // unstage all files + await Bun.$`git restore --staged .`.cwd("../data").nothrow().quiet().then(handleShellErr); + await fetchGitChanged(); + // stage files + await Bun.$`git add ${{ raw: files.map((x) => Bun.$.escape(x)).join(" ") }}` + .cwd("../data") + .nothrow() + .quiet() + .then(handleShellErr); + // commiiiiiit + await Bun.$`git commit -m ${message}` + .cwd("../data") + .nothrow() + .quiet() + .then((e) => void e); + } + + resolve(); + + const promInd = gitQueue.indexOf(promise); + if (promInd !== -1) gitQueue.splice(promInd, 1); +} diff --git a/src/update/index.ts b/src/update/index.ts new file mode 100644 index 00000000000..1394f41ea0f --- /dev/null +++ b/src/update/index.ts @@ -0,0 +1,89 @@ +import { exists, mkdir, rm } from "node:fs/promises"; +import { log, makeProgress, wrapPromise } from "./progress"; +import { runTasks } from "./runner"; +import { type apkSplits, apksFolder, isMock, trackerUserAgent, version, workFolder } from "./shared"; +import { handleShellErr, join } from "./utils"; + +const cdnUrl = `https://tracker.vendetta.rocks/tracker/download/${version}/`; + +const mediaFiles = Bun.$.braces("res/**/*.{png,jpg,lottie}"); +const apkAssets = { + base: ["assets/index.android.bundle", ...mediaFiles], + "config.hdpi": mediaFiles, + "config.xxhdpi": mediaFiles, +} satisfies Record<(typeof apkSplits)[number], string[]>; + +let reuseFolder = isMock; +if (!reuseFolder) { + const matches = await Bun.file("tmp/ver") + .text() + .then((x) => x === version) + .catch(() => false); + if (matches) { + const exist = await Promise.allSettled( + Object.keys(apkAssets).map((folder) => exists(join(apksFolder, folder, "res"))), + ); + if (exist.every((x) => x.status === "fulfilled" && x.value)) reuseFolder = true; + } +} + +// Temp folder yippee! + +if (!reuseFolder) await rm(workFolder, { force: true, recursive: true }); +if (!(await exists(apksFolder))) await mkdir(apksFolder, { recursive: true }); + +if (!reuseFolder) { + // Download the APKs + log("Downloading APKs..."); + + const downloadProgress = makeProgress( + Object.fromEntries(Object.keys(apkAssets).map((apk) => [apk, `Downloading ${apk}.apk`])), + ); + await Promise.allSettled( + Object.keys(apkAssets).map((apk) => { + return wrapPromise( + fetch(cdnUrl + apk, { + headers: { + "User-Agent": trackerUserAgent, + "Cache-Control": "public, max-age=3600", + }, + }) + .then((res) => res.arrayBuffer()) + .then((data) => Bun.write(join(workFolder, `${apk}.zip`), data)), + downloadProgress, + apk, + ); + }), + ); + if (downloadProgress.anyFailed()) throw new Error(`Failed to download all APKs!\n${downloadProgress.prettyErrors()}`); + + log("\nUnzipping APKs..."); + + const unzipProgress = makeProgress( + Object.fromEntries(Object.keys(apkAssets).map((apk) => [apk, `Unzipping ${apk}.apk`])), + ); + + await Promise.allSettled( + Object.entries(apkAssets).map(([apk, assets]) => + wrapPromise( + Bun.$`unzip -o ${join(workFolder, `${apk}.zip`)} ${{ + raw: assets.map((file) => Bun.$.escape(file)).join(" "), + }} -d ${join(apksFolder, apk)} 2>/dev/null` + .quiet() + .nothrow() + .then(handleShellErr), + unzipProgress, + apk, + ), + ), + ); + if (unzipProgress.anyFailed()) throw new Error(`Failed to unzip all APKs!\n${unzipProgress.prettyErrors()}`); + + await Bun.write(join(workFolder, "ver"), version); +} else log("Reusing folder!"); + +// Run tasks + +await runTasks(); + +log("✅ Done"); diff --git a/src/update/progress.ts b/src/update/progress.ts new file mode 100644 index 00000000000..96d8c025598 --- /dev/null +++ b/src/update/progress.ts @@ -0,0 +1,112 @@ +import { formatError } from "./utils"; + +let somethingInLog = false; +export function log(...messages: any) { + console.log(...messages); + somethingInLog = true; +} + +export function makeProgress(data: Record, startPaused = false) { + type Key = keyof typeof data; + const keys = Object.keys(data); + + const result = new Map(); + const errors = new Map(); + if (startPaused) for (const key of keys) result.set(key, "paused"); + + let logs: string[] = []; + const reprint = () => { + const oldLogs = logs; + logs = keys.map((key) => { + const rootKey = key.split("_")[1] && keys.includes(key.split("_")[0]) && key.split("_")[0]; + const otherKeys = keys.filter((x) => x.split("_")[0] === rootKey); + const isLast = otherKeys.indexOf(key) === otherKeys.length - 1; + + return `${rootKey ? (isLast ? " ╚═ " : " ╠═ ") : ""}${ + { + success: "✅", + failed: "❌", + skipped: "💫", + paused: "🔔", + idle: "⏰", + }[result.get(key) || "idle"] + } ${data[key]}`; + }); + + if (!oldLogs.length) console.log(logs.join("\n")); + else if ( + oldLogs.length === logs.length && + process.stdout.moveCursor && + process.stdout.clearLine && + !somethingInLog + ) { + for (let i = 0; i < logs.length; i++) + if (logs[i] !== oldLogs[i]) { + const j = logs.length - i; + process.stdout.moveCursor(-500, -j); + process.stdout.clearLine(1); + process.stdout.write(logs[i]); + process.stdout.moveCursor(-500, j); + } + } else { + somethingInLog = false; + console.log(`\n${logs.join("\n")}`); + } + }; + + somethingInLog = false; + reprint(); + + const bKeyer = (keys: Key[]) => { + const bKeys: Key[] = []; + for (const k of keys) bKeys.push(k, ...keys.filter((x) => x.startsWith(`${k}_`))); + return bKeys; + }; + + return { + update(key: Key, value: boolean | null, error?: any) { + if (value === true) result.set(key, "success"); + else if (value === false) result.set(key, "failed"); + else if (value === null) result.set(key, "skipped"); + + if (error) errors.set(key, error); + reprint(); + }, + start(key: Key) { + result.set(key, "idle"); + reprint(); + }, + pause(key: Key) { + result.set(key, "paused"); + reprint(); + }, + + anyFailed: () => [...result.values()].some((x) => x === "failed"), + someFailed: (...keys: Key[]) => bKeyer(keys).some((k) => result.get(k) === "failed"), + isFinished: (key: Key) => ["success", "failed", "skipped"].some((x) => result.get(key) === x), + + keys, + errors, + prettyErrors(...keys: Key[]) { + const bKeys = bKeyer(keys); + let err = [...errors.entries()]; + + if (keys[0]) err = err.filter(([k]) => bKeys.includes(k)); + return err.map(([k, v]) => `${k} => ${v}`).join("\n"); + }, + }; +} + +export type Progress = ReturnType; + +export async function wrapPromise(promise: Promise, progress: Progress, key: string): Promise { + progress.start(key); + try { + const value = await promise; + progress.update(key, true); + return value; + } catch (e: any) { + progress.update(key, false, formatError(e)); + throw e; + } +} diff --git a/src/update/runner.ts b/src/update/runner.ts new file mode 100644 index 00000000000..9c010b3af58 --- /dev/null +++ b/src/update/runner.ts @@ -0,0 +1,124 @@ +import { exists, rm } from "node:fs/promises"; +import mock from "../mock"; +import type { Differs } from "../types"; +import { commit } from "./git"; +import { log, makeProgress, wrapPromise } from "./progress"; +import { apksFolder, codePath, cuteVersion, isMock, isQuiet, oprevFiles, prevFiles, version } from "./shared"; +import codeTask from "./tasks/code"; +import colors from "./tasks/colors"; +import decompile from "./tasks/decompile"; +import diffs from "./tasks/diffs"; +import icons from "./tasks/icons"; +import { webhook } from "./tasks/webhook"; +import { formatError, handleShellErr, join } from "./utils"; + +export async function runTasks() { + log("\nRunning tasks..."); + const progress = makeProgress( + { + preinit: "Preinit", + preinit_discard: "Discarding changes", + preinit_save: "Caching original files", + decompile: "Decompilation", + decompile_downloading: "Downloading decompiler", + decompile_decompiling: "Decompiling index.android.bundle", + decompile_gzip: "Compressing code", + code: "Code parser", + code_getting: "Finding file imports", + code_remaking: "Recreating code", + code_pushing: "Committing source", + colors: "Parsing colors", + icons: "Icon parser", + icons_getting: "Writing icons.json", + icons_copying: "Copying images", + diff: "Diffs", + diff_raw: "Diffing raw colors", + diff_semantic: "Diffing semantic colors", + diff_icons: "Diffing icons", + diff_code: "Diffing code", + webhook: "Sending webhook messages", + }, + true, + ); + let differs: Differs | undefined; + + if (!isMock) { + // discard changes + try { + progress.start("preinit"); + + // reset to remote branch (without pulling latest commit) + progress.start("preinit_discard"); + await Bun.$`git reset --hard`.cwd("../data").nothrow().quiet().then(handleShellErr); + if (await exists(join("../data", "oldicons"))) + await rm(join("../data", "oldicons"), { force: true, recursive: true }); + await Bun.write("../data/version.txt", version); // lel + + // unstage all files + await Bun.$`git restore --staged .`.cwd("../data").nothrow().quiet().then(handleShellErr); + + progress.update("preinit_discard", true); + + progress.start("preinit_save"); + for (const oprev of oprevFiles) { + const file = Bun.file(join("../data", oprev)); + if (await file.exists()) prevFiles.set(oprev, await file.arrayBuffer()); + } + progress.update("preinit_save", true); + progress.update("preinit", true); + } catch (e) { + progress.update("preinit", false); + throw new Error(`Failed to discard changes!\n${e}`); + } + + try { + await wrapPromise( + decompile(progress, join(apksFolder, "base", "assets", "index.android.bundle")), + progress, + "decompile", + ); + } catch (e) { + throw new Error(`Failed to decompile!\n${e}`); + } + + const code = (await Bun.file(codePath).text()).replace(/\r/g, "").split("\n"); + + await Promise.allSettled([ + wrapPromise(codeTask(progress, code), progress, "code"), + wrapPromise(colors(code), progress, "colors"), + wrapPromise(icons(progress, code), progress, "icons"), + ]); + if (progress.someFailed("code", "colors", "icons")) + throw new Error(`Failed at parser tasks!\n${progress.prettyErrors("code", "colors", "icons")}`); + + while (!progress.isFinished("decompile_gzip")) { + await Bun.sleep(1000); + } + + if (progress.someFailed("decompile_gzip")) + throw new Error(`Failed at the decompile gzip task!\n${progress.prettyErrors("decompile_gzip")}`); + + try { + differs = await diffs(progress); + progress.update("diff", true); + } catch (e: any) { + progress.update("diff", false, formatError(e)); + throw new Error(`Failed to generate diffs!\n${formatError(e)}`); + } + } else { + differs = mock; + } + + if (differs && !isQuiet) { + try { + await wrapPromise(webhook(differs), progress, "webhook"); + } catch (e: any) { + throw new Error(`Failed to send webhook messages!\n${formatError(e)}`); + } + } else { + progress.update("webhook", null); + } + + await commit(["version.txt"], `chore: bump app version to ${cuteVersion}`); + await rm("../data/oldicons", { force: true, recursive: true }); +} diff --git a/src/update/shared.ts b/src/update/shared.ts new file mode 100644 index 00000000000..3d09c45e6ef --- /dev/null +++ b/src/update/shared.ts @@ -0,0 +1,20 @@ +import { join } from "node:path"; + +export const trackerUserAgent = "Themelings "; +export const maxDiffChanges = 20; + +export const workFolder = "tmp"; +export const apksFolder = join(workFolder, "apks"); +export const codePath = join(workFolder, "code.js"); +export const apkSplits = ["base", "config.hdpi", "config.xxhdpi"] as const; + +export const oprevFiles = ["semantic.json", "raw.json", "icons.json", "source.jsonl"] as const; +export const prevFiles = new Map<(typeof oprevFiles)[number], ArrayBuffer>(); + +export const version = await Bun.file("../data/version.txt").text(); +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 diffAnyway = process.argv.includes("--diff-anyway"); +export const commitAnyway = process.argv.includes("--commit-anyway"); +export const isQuiet = process.argv.includes("--quiet"); diff --git a/src/update/tasks/code.ts b/src/update/tasks/code.ts new file mode 100644 index 00000000000..533d0b53b2b --- /dev/null +++ b/src/update/tasks/code.ts @@ -0,0 +1,96 @@ +import { mkdir, rm } from "node:fs/promises"; +import { deminify } from "../deminify"; +import { commit } from "../git"; +import type { Progress } from "../progress"; +import { commitAnyway, cuteVersion } from "../shared"; +import { discordPath, join, listRequiredDirs, sortEntries } from "../utils"; + +// scary code matching below, be warned + +const moduleStartIndentation = " ".repeat(4); +const pathCheckRegex = / = '(.*?)';$/; +const lineImportRegex = / = r\d{1,2}\.fileFinishedImporting;$/; + +export default async function code(progress: Progress, code: string[]) { + progress.start("code_getting"); + const files = new Map(); + + let moduleStart: number | null = null; + + for (let i = 0; i < code.length; i++) { + const line = code[i]; + + // find the start of the file import based on the indentation + if ( + new RegExp(`^${moduleStartIndentation}r\\d{1,2} = function\\(`).test(line) && + line.includes(" { // Environment:") + ) { + moduleStart = i; + } + + // easiest way to check + const path = code[i + 1]?.match(pathCheckRegex)?.[1]; + if (line.match(lineImportRegex) && path) { + const start = moduleStart; + if (!start) throw `moduleStart was null for ${start}~${i}; null ~ ${code[i]}`; + moduleStart = null; + + // find the end of the file import based on the indentation + const moduleEndThing = `${moduleStartIndentation}};`; + let moduleEnd: number | null = null; + for (let j = i; j < code.length; j++) { + if (code[j] === moduleEndThing) { + moduleEnd = i = j; + break; + } + } + + if (!moduleEnd) throw `moduleEnd was null for ${start}~${i}; ${code[start]} ~ ${code[i]}`; + + const realPath = discordPath(path); + files.set( + realPath, + deminify( + code + .slice(start, moduleEnd + 1) + .map((line) => line.replace(new RegExp(`^${moduleStartIndentation}`), "")) + .join("\n"), + realPath, + ), + ); + } + } + + await Bun.write( + "../data/source.jsonl", + sortEntries([...files.entries()]) + .map(([file, text]) => `{ "file": ${JSON.stringify(file)}, "size": ${text.final.length} }`) + .join("\n"), + ); + + progress.update("code_getting", true); + + if (process.env.NODE_ENV !== "test" && !commitAnyway) { + progress.start("code_remaking"); + + const filePrefix = "../data/source"; + await rm(filePrefix, { recursive: true, force: true }); + + const dirs = listRequiredDirs([...files.keys()]); + + await Promise.all(dirs.map((dir) => mkdir(join(filePrefix, dir), { recursive: true }))); + await Promise.all(files.entries().map(([file, text]) => Bun.write(join(filePrefix, file), text.res))); + + progress.update("code_remaking", true); + progress.start("code_pushing"); + + // hope the files get written in this time idk :P blehhh + await Bun.sleep(1500); + + await commit(["source.jsonl", "source/*"], `chore: update source for ${cuteVersion}`); + progress.update("code_pushing", true); + } else { + progress.update("code_remaking", null); + progress.update("code_pushing", null); + } +} diff --git a/src/update/tasks/colors.test.ts b/src/update/tasks/colors.test.ts new file mode 100644 index 00000000000..2046d656a75 --- /dev/null +++ b/src/update/tasks/colors.test.ts @@ -0,0 +1,14 @@ +import { sortObj } from "../utils"; +import { convertSimpleSemantic, getInternalRawColors, getInternalSemanticColors } from "./colors"; + +const codeFile = Bun.file("tmp/code.js"); +if (!(await codeFile.exists())) throw "Missing decompiled code.js"; + +const code = (await codeFile.text()).replace(/\r/g, "").split("\n"); + +const raw = getInternalRawColors(code); +await Bun.write("temp/raw.json", JSON.stringify(sortObj(raw), undefined, 4)); + +const semantic = getInternalSemanticColors(code, raw); +await Bun.write("temp/semantic.json", JSON.stringify(sortObj(semantic), undefined, 4)); +await Bun.write("temp/semantic_simple.json", JSON.stringify(sortObj(convertSimpleSemantic(semantic)), null, 4)); diff --git a/src/update/tasks/colors.ts b/src/update/tasks/colors.ts new file mode 100644 index 00000000000..310b3f11760 --- /dev/null +++ b/src/update/tasks/colors.ts @@ -0,0 +1,133 @@ +import { runInNewContext } from "node:vm"; +import Color, { type ColorInstance } from "color"; +import type { RawColors, SemanticColors } from "../../types"; +import { commit } from "../git"; +import { cuteVersion } from "../shared"; +import { sortObj } from "../utils"; + +function hex(color: ColorInstance) { + return (color.alpha() === 1 ? color.hex() : color.hexa()).toLowerCase(); +} + +// FIXME fixes an issue introduced in hermes-dec commit 1c112f85e3fb49442c57a0ed31b83bb3825db639, +// where code such as "rX[rY] = z" is instead parsed as "rX[Y] = z" +function fixHermesDecIndex(code: string) { + return code.replace(/r(\d+)\[(\d+)\] =/g, "r$1[r$2] ="); +} + +// more scary code matching below, be warned + +export function evalMetroModule(code: string[], importName: string, imports: object = {}): T { + const hookLine = code.findIndex((x) => x.includes(importName)); + if (hookLine === -1) throw new Error(`Cannot find hookLine for ${importName}!`); + + // usually fileFinishedImporting is called 5 lines before the function ends, so we pray + const endLine = hookLine + 5; + + let startLine: number | null = null; + for (let i = hookLine; i > 0; i--) + if (code[i].includes("// Environment:")) { + startLine = i; + break; + } + + if (startLine === null) throw new Error(`Failed to find startLine for ${importName}!`); + + const snippet = fixHermesDecIndex(code.slice(startLine, endLine).join("\n")); + const fn = runInNewContext(snippet, { + global: { + Object: Object.assign(Object, { + freeze(obj: any) { + return obj; + }, + }), + }, + }); + + const exports: any = {}; + fn( + null, // global + () => ({ + fileFinishedImporting() {}, + ...imports, + }), // require + null, // metroImportDefault + null, // metroImportAll + null, // moduleObject + exports, // exports + [], // dependencyMap + ); + + return exports; +} + +export function getInternalRawColors(code: string[]) { + const module = evalMetroModule<{ + _private: { + RawColors: RawColors; + }; + }>(code, "/tokens/colors/generated/raw-color-definitions.tsx'", {}); + + const raw: RawColors = {}; + for (const [key, color] of Object.entries(module._private.RawColors)) { + raw[key] = hex(Color(color)); + } + + return raw; +} + +export function getInternalSemanticColors(code: string[], raw: Record) { + const module = evalMetroModule<{ + _private: { + SemanticColors: Record>; + }; + }>(code, "/tokens/colors/generated/native/generated-definitions.tsx'", { + _private: { + Themes: new Proxy( + {}, + { + get(_, key) { + return key.toString().toLowerCase(); + }, + }, + ), + }, + }); + + const semantic: SemanticColors = {}; + for (const [key, colors] of Object.entries(module._private.SemanticColors)) { + const color: SemanticColors[string] = {}; + for (const [theme, val] of Object.entries(colors)) { + if (typeof val !== "object" || !("raw" in val)) continue; + + color[theme] = [hex(Color(raw[val.raw]).alpha(val.opacity)), { raw: val.raw, opacity: val.opacity }]; + } + + semantic[key] = color; + } + + return semantic; +} + +export function convertSimpleSemantic(semantic: SemanticColors) { + const simpleSemantic: Record> = {}; + for (const [key, colors] of Object.entries(semantic)) { + const color: Record = {}; + for (const [theme, [clr]] of Object.entries(colors)) color[theme] = clr; + + simpleSemantic[key] = color; + } + + return simpleSemantic; +} + +export default async function colors(code: string[]) { + const raw = getInternalRawColors(code); + await Bun.write("../data/raw.json", JSON.stringify(sortObj(raw), null, 4)); + + const semantic = getInternalSemanticColors(code, raw); + await Bun.write("../data/semantic.json", JSON.stringify(sortObj(semantic), null, 4)); + await Bun.write("../data/semantic_simple.json", JSON.stringify(sortObj(convertSimpleSemantic(semantic)), null, 4)); + + await commit(["raw.json", "semantic.json", "semantic_simple.json"], `chore: update colors for ${cuteVersion}`); +} diff --git a/src/update/tasks/decompile-gzip.ts b/src/update/tasks/decompile-gzip.ts new file mode 100644 index 00000000000..438369a0402 --- /dev/null +++ b/src/update/tasks/decompile-gzip.ts @@ -0,0 +1,11 @@ +// prevents TS errors +declare let self: Worker; + +self.addEventListener("message", async ({ data: { path, target } }) => { + await Bun.write( + target, + Bun.gzipSync(await Bun.file(path).arrayBuffer(), { level: 9 }), // slowest speed, best compression + ); + + self.postMessage(true); +}); diff --git a/src/update/tasks/decompile.ts b/src/update/tasks/decompile.ts new file mode 100644 index 00000000000..ebfd17429f3 --- /dev/null +++ b/src/update/tasks/decompile.ts @@ -0,0 +1,51 @@ +import { exists } from "node:fs/promises"; +import { commit } from "../git"; +import type { Progress } from "../progress"; +import { codePath, commitAnyway, cuteVersion, workFolder } from "../shared"; +import { handleShellErr, join } from "../utils"; + +const gzipWorkerURL = new URL("decompile-gzip.ts", import.meta.url).href; +const decompilerPy = "src/hermes_dec/decompilation/hbc_decompiler.py"; + +export default async function decompile(progress: Progress, pathToBundle: string) { + const pathToDecompiler = join(workFolder, "decompiler"); + + progress.start("decompile_downloading"); + if (!(await exists(pathToDecompiler))) { + await Bun.$`git clone https://github.com/P1sec/hermes-dec.git --depth=1 ${pathToDecompiler}` + .quiet() + .nothrow() + .then(handleShellErr); + progress.update("decompile_downloading", true); + } else progress.update("decompile_downloading", null); + + const { exitCode: hasPython } = await Bun.$`python --version`.nothrow().quiet(); + if (hasPython !== 0) throw new Error("Cannot use Python! Are you sure it's installed?"); + + progress.start("decompile_decompiling"); + if (!(await Bun.file(codePath).exists())) { + await Bun.$`python ${join(pathToDecompiler, decompilerPy)} ${pathToBundle} ${codePath}` + .quiet() + .nothrow() + .then(handleShellErr); + progress.update("decompile_decompiling", true); + } else progress.update("decompile_decompiling", null); + + // background step + if (process.env.NODE_ENV !== "test" && !commitAnyway) { + const gzFile = "code.js.gz"; + + const gzipper = new Worker(gzipWorkerURL); + progress.start("decompile_gzip"); + gzipper.addEventListener("message", async ({ data }) => { + if (data === true) { + await commit([gzFile], `chore: update decompiled code for ${cuteVersion}`); + progress.update("decompile_gzip", true); + } + gzipper.terminate(); + }); + gzipper.postMessage({ path: codePath, target: join("../data", gzFile) }); + } else { + progress.update("decompile_gzip", null); + } +} diff --git a/src/update/tasks/diffs.ts b/src/update/tasks/diffs.ts new file mode 100644 index 00000000000..14a0ac27dca --- /dev/null +++ b/src/update/tasks/diffs.ts @@ -0,0 +1,290 @@ +import { + type CodeDiff, + type Diff, + type Differs, + DiffType, + type Icons, + type RawColors, + type Semantic, + type SemanticColors, +} from "../../types"; +import { fetchGitChanged, gitChanged } from "../git"; +import { type Progress, wrapPromise } from "../progress"; +import { diffAnyway, prevFiles } from "../shared"; +import { join, parseJsonl } from "../utils"; + +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("../data", "raw.json")).json(); + + const renamed = new Set(); + const changes = new Map(); + for (const [name, source] of Object.entries(newRaw)) + if (!oldRaw[name]) { + const oldName = Object.entries(oldRaw).find(([key, val]) => !newRaw[key] && val === source)?.[0]; + + if (!oldName) + changes.set(name, { + type: DiffType.Added, + source, + }); + else { + renamed.add(oldName); + changes.set(name, { + type: DiffType.Renamed, + oldName, + source, + }); + } + } else if (oldRaw[name].toLowerCase() !== source.toLowerCase()) + changes.set(name, { + type: DiffType.Changed, + source, + oldSource: oldRaw[name], + }); + for (const [name, source] of Object.entries(oldRaw)) + if (!(newRaw[name] || renamed.has(name))) + changes.set(name, { + type: DiffType.Removed, + source, + }); + + return changes; +} + +function getSemanticLabel(semantic: Semantic) { + const light = semantic.light, + dark = semantic.darker ?? semantic.dark; + if (dark && light) return `☀️ ${light[0]}, 🌙 ${dark[0]}`; + return Object.entries(semantic) + .map(([name, [color]]) => `${name} ${color}`) + .join(", "); +} + +async function diffSemantic() { + 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 newSemantic: SemanticColors = await Bun.file(join("../data", "semantic.json")).json(); + + const renamed = new Set(); + const changes = new Map(); + for (const [name, semantic] of Object.entries(newSemantic)) { + const source = Object.values(semantic) + .map(([color]) => color) + .join(","); + if (!oldSemantic[name]) { + const themes = Object.keys(semantic).length; + const oldName = Object.entries(oldSemantic).find( + ([key, val]) => + !newSemantic[key] && + Object.keys(val).length === themes && + Object.entries(val).every(([skey, sval]) => sval[0] === semantic[skey][0]), + )?.[0]; + + if (!oldName) + changes.set(name, { + type: DiffType.Added, + source, + label: getSemanticLabel(semantic), + }); + 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}`, { + type: DiffType.Changed, + source: color, + oldSource: oldSemantic[name][theme][0], + }); + } + for (const [name, semantic] of Object.entries(oldSemantic)) { + if (renamed.has(name)) continue; + + const source = Object.values(semantic) + .map(([color]) => color) + .join(","); + if (!newSemantic[name]) + changes.set(name, { + 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; +} + +async function diffIcons() { + 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 newIcons: Icons = await Bun.file(join("../data", "icons.json")).json(); + + const iconDir = { + old: join("../data", "oldicons"), + new: join("../data", "icons"), + }; + + const renamed = new Set(); + const changes = new Map(); + for (const [name, icon] of Object.entries(newIcons)) { + const label = icon.hash.slice(0, 8); + if (!oldIcons[name]) { + const oldName = Object.entries(oldIcons).find(([key, val]) => !newIcons[key] && val.hash === icon.hash)?.[0]; + + 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) { + const source = parseJsonl(text) as { file: string; size: number }[]; + return Object.fromEntries( + source.map(({ file, size }) => [file, { size, compSize: size - JSON.stringify(file).length }]), + ) as Record; +} + +async function diffCode() { + if (!prevFiles.has("source.jsonl")) { + throw new Error("Missing prevFile: source.jsonl"); + } + + const oldCode = parseSource(new TextDecoder().decode(prevFiles.get("source.jsonl"))); + const newCode = parseSource(await Bun.file(join("../data", "source.jsonl")).text()); + + const renamed = new Set(); + const changes = new Map(); + 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) { + await fetchGitChanged(); + + if (!(gitChanged.has("version.txt") || diffAnyway)) { + progress.update("diff", null); + progress.update("diff_raw", null); + progress.update("diff_semantic", null); + progress.update("diff_icons", null); + progress.update("diff_code", null); + return; + } + + const differs = {} as Differs; + + await Promise.all([ + gitChanged.has("raw.json") + ? wrapPromise( + diffRaw().then((x) => (differs.raw = x)), + progress, + "diff_raw", + ) + : progress.update("diff_raw", null), + gitChanged.has("semantic.json") + ? wrapPromise( + diffSemantic().then((x) => (differs.semantic = x)), + progress, + "diff_semantic", + ) + : progress.update("diff_semantic", null), + gitChanged.has("icons.json") + ? wrapPromise( + diffIcons().then((x) => (differs.icons = x)), + progress, + "diff_icons", + ) + : progress.update("diff_icons", null), + gitChanged.has("source.jsonl") + ? wrapPromise( + diffCode().then((x) => (differs.code = x)), + progress, + "diff_code", + ) + : progress.update("diff_code", null), + ]); + + progress.update("diff", true); + return differs; +} diff --git a/src/update/tasks/icons.test.ts b/src/update/tasks/icons.test.ts new file mode 100644 index 00000000000..d48262aed2a --- /dev/null +++ b/src/update/tasks/icons.test.ts @@ -0,0 +1,10 @@ +import { sortObj } from "../utils"; +import { parseAssets } from "./icons"; + +const codeFile = Bun.file("tmp/code.js"); +if (!(await codeFile.exists())) throw "Missing decompiled code.js"; + +const code = (await codeFile.text()).replace(/\r/g, "").split("\n"); + +const { icons } = await parseAssets(code); +await Bun.write("temp/icons.json", JSON.stringify(sortObj(icons), undefined, 4)); diff --git a/src/update/tasks/icons.ts b/src/update/tasks/icons.ts new file mode 100644 index 00000000000..6d62b22ca90 --- /dev/null +++ b/src/update/tasks/icons.ts @@ -0,0 +1,114 @@ +import { copyFile, mkdir, readdir, rename } from "node:fs/promises"; +import { basename } from "node:path"; +import { runInNewContext } from "node:vm"; +import type { Icons } from "../../types"; +import { commit } from "../git"; +import type { Progress } from "../progress"; +import { apkSplits, apksFolder, cuteVersion } from "../shared"; +import { discordPath, join, listRequiredDirs, sortObj } from "../utils"; + +const infoObjRegex = /({.+?})/; +const scalesArrayRegex = /(\[.+?\])/; + +// a bit less scary code matching below, be warned +export async function parseAssets(code: string[]) { + const retrievedAssets: { + httpServerLocation: string; + width: number; + height: number; + scales: number[]; + hash: string; + name: string; + type: "png" | "svg" | "lottie"; + }[] = []; + + for (let i = 0; i < code.length; i++) { + // next line has asset info, after that are scales + const line = code[i], + infoLine = code[i + 1], + scalesLine = code[i + 2]; + + if (line.includes(".registerAsset") && infoLine?.includes("'httpServerLocation'") && scalesLine?.includes("[")) { + const infoText = infoLine.match(infoObjRegex)?.[1]; + if (!infoText) throw new Error(`Failed to find infoText for ${infoLine} (line ${i + 1})`); + + const scalesText = scalesLine.match(scalesArrayRegex)?.[1]; + if (!scalesText) throw new Error(`Failed to find scalesText for ${scalesLine} (line ${i + 2})`); + + const info = runInNewContext(`(${infoText})`); + if (typeof info !== "object") continue; + + const scales = runInNewContext(`(${scalesText})`); + if (!Array.isArray(scales)) continue; + + info.scales = scales; + + if ( + ["httpServerLocation", "hash", "name", "type"].every((x) => x in info) && + ["svg", "png", "lottie"].includes(info.type) && + !info.httpServerLocation.includes("node_modules/.pnpm") + ) { + retrievedAssets.push(info); + } + } + } + + // get files of all apks by hierarchy + const apkPaths = new Map(); + for (const split of apkSplits) { + const folder = join(apksFolder, split); + for (const path of await readdir(folder, { recursive: true })) apkPaths.set(basename(path), join(folder, path)); + } + + const icons: Icons = {}; + const toCopy: { from: string; to: string }[] = []; + for (const asset of retrievedAssets) { + const baseName = [...asset.httpServerLocation.split("/").slice(2), asset.name] + .join("_") + .toLowerCase() + .replace(/\W+/g, ""); + const path = `${baseName}.${asset.type}`; + + const apkPath = apkPaths.get(path); + if (apkPath) { + const realPath = join( + discordPath(asset.httpServerLocation.split("/").slice(2).join("/")), + `${asset.name}.${asset.type}`, + ); + + icons[asset.name] = { + file: realPath, + hash: asset.hash, + scales: asset.scales, + width: asset.width ?? null, + height: asset.height ?? null, + }; + toCopy.push({ + from: apkPath, + to: join("../data/icons", realPath), + }); + } + } + + return { icons, toCopy }; +} + +export default async function icons(progress: Progress, code: string[]) { + progress.start("icons_getting"); + + const { icons, toCopy } = await parseAssets(code); + await Bun.write("../data/icons.json", JSON.stringify(sortObj(icons), undefined, 4)); + progress.update("icons_getting", true); + + progress.start("icons_copying"); + + // don't delete the old icons folder so we can display the old images + await rename("../data/icons", "../data/oldicons"); + const dirs = listRequiredDirs(toCopy.map((x) => x.to)); + + await Promise.all(dirs.map((dir) => mkdir(dir, { recursive: true }))); + await Promise.all(toCopy.map(({ from, to }) => copyFile(from, to))); + + await commit(["icons.json", "icons"], `chore: update icons for ${cuteVersion}`); + progress.update("icons_copying", true); +} diff --git a/src/update/tasks/webhook.ts b/src/update/tasks/webhook.ts new file mode 100644 index 00000000000..5761e44cde8 --- /dev/null +++ b/src/update/tasks/webhook.ts @@ -0,0 +1,271 @@ +import { basename } from "node:path"; +import type { Canvas } from "@napi-rs/canvas"; +import { + type APIMessageTopLevelComponent, + ComponentType, + MessageFlags, + type RESTPostAPIWebhookWithTokenJSONBody, + RouteBases, + Routes, +} from "discord-api-types/v10"; +import { drawSections } from "../../canvas"; +import { makeSections } from "../../canvas/factory"; +import { type CodeDiff, type Diff, type Differs, DiffType } from "../../types"; +import { cuteVersion, maxDiffChanges, version } from "../shared"; +import { assert, formatBytes, sortEntries } from "../utils"; + +function fileBase(path: string, other?: string) { + if (!other) return `... ${basename(path)}`; + const paths = path.split("/"), + others = other.split("/"); + + let offset = 0; + while (offset < Math.min(paths.length, others.length) - 1 && paths[offset] === others[offset]) { + offset++; + } + + return `... ${paths.slice(offset).join("/")}`; +} + +function makeFooter(size: number, item: string) { + return `**${size.toLocaleString("en-US")}** ${item} change${size !== 1 ? "s" : ""} total`; +} + +function getThreshold(lines: string[]) { + return lines.some((x) => x.includes("\n")) ? Math.floor(maxDiffChanges / 2) : maxDiffChanges; +} + +function cap(lines: string[], item: string) { + const threshold = getThreshold(lines); + if (lines.length > threshold) + return [...lines.slice(0, threshold), `(+${(lines.length - threshold).toLocaleString("en-US")} ${item})`]; + return lines; +} + +interface FormattedDiff { + embed: string[]; + file: string; + capped: boolean; +} + +function formatDiff(diffs: Map): FormattedDiff { + const entries = [...diffs.entries()]; + + const sections = { + Added: sortEntries(entries) + .map( + ([name, diff]) => + diff.type === DiffType.Added && + ("size" in diff + ? `+ ${fileBase(name)} (${formatBytes(diff.size)})` + : `+ ${name}: ${diff.label || diff.source}`), + ) + .filter((x) => typeof x === "string"), + Changed: sortEntries(entries) + .map( + ([name, diff]) => + diff.type === DiffType.Changed && + `- ${name}: ${diff.oldLabel || diff.oldSource}\n+ ${name}: ${diff.label || diff.source}`, + ) + .filter((x) => typeof x === "string"), + Renamed: sortEntries(entries) + .map( + ([name, diff]) => + diff.type === DiffType.Renamed && + ("size" in diff + ? `- ${fileBase(diff.oldName, name)}\n+ ${fileBase(name, diff.oldName)}` + : `- ${diff.oldName}\n+ ${name}`), + ) + .filter((x) => typeof x === "string"), + Removed: sortEntries(entries) + .map( + ([name, diff]) => + diff.type === DiffType.Removed && + ("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); + return { + embed: text.map( + ([title, lines]) => `### ${title}\n\`\`\`diff\n${cap(lines, title.toLowerCase()).join("\n")}\`\`\``, + ), + file: text.map(([title, lines]) => `# ${title}\n\n${lines.join("\n")}`).join("\n\n"), + capped: text.some(([, lines]) => lines.length > getThreshold(lines)), + }; +} + +interface WebhookEmbed { + title: string; + body: FormattedDiff; + image?: Canvas; + footer: string; + key: string; +} + +async function sendWebhook(webhook: string, role: string, embeds: WebhookEmbed[], color?: number) { + const body = new FormData(); + const images = new Map(), + diffs = new Map(); + + let fi = 0; + for (let i = 0; i < embeds.length; i++) { + const embed = embeds[i]; + if (embed.image) { + const key = fi++; + const filename = `${embed.key}.png`; + images.set(i, filename); + + body.append(`files[${key}]`, new Blob([embed.image.toBuffer("image/png")], { type: "image/png" }), filename); + } + if (embed.body.capped) { + const key = fi++; + const filename = `${embed.key}-changes.diff`; + diffs.set(i, filename); + + body.append(`files[${key}]`, new Blob([embed.body.file], { type: "text/plain" }), filename); + } + } + + body.append( + "payload_json", + JSON.stringify({ + flags: MessageFlags.IsComponentsV2, + components: [ + { + type: ComponentType.TextDisplay, + content: `<@&${role}>`, + }, + ...embeds.map( + ({ title, body, image, footer }, i) => + ({ + type: ComponentType.Container, + accent_color: color, + components: [ + { + type: ComponentType.TextDisplay, + content: `## ${title}\n-# ${version} (${cuteVersion})`, + }, + ...body.embed.map((content) => ({ + type: ComponentType.TextDisplay, + content, + })), + body.capped && { + type: ComponentType.File, + file: { + url: `attachment://${diffs.get(i)}`, + }, + }, + body.capped && { + type: ComponentType.Separator, + }, + image && { + type: ComponentType.MediaGallery, + items: [ + { + media: { + url: `attachment://${images.get(i)}`, + }, + }, + ], + }, + { + type: ComponentType.TextDisplay, + content: footer, + }, + ].filter((x) => !!x), + }) as APIMessageTopLevelComponent, + ), + ], + allowed_mentions: process.env.NODE_ENV === "test" ? { parse: [] } : { roles: [role] }, + } as RESTPostAPIWebhookWithTokenJSONBody), + ); + + const url = new URL(webhook); + url.searchParams.set("wait", "true"); + url.searchParams.set("with_components", "true"); + + const res = await fetch(url, { + method: "POST", + body, + }); + + const data = await res.text(); + if (!res.ok) + throw new Error(`Failed to send webhook message with embeds ${embeds.map((x) => x.title).join(", ")}: ${data}`); + + const discordToken = process.env.bot_token; + if (!discordToken) return; + + // this can fail quietly + try { + const message = JSON.parse(data); + + const route = `${RouteBases.api}/${Routes.channelMessageCrosspost(message.channel_id, message.id)}`; + await fetch(route, { + method: "POST", + headers: { + Authorization: discordToken, + }, + }); + } catch {} +} + +export async function webhook(diffs: Differs) { + if (diffs.raw?.size || diffs.semantic?.size) + await sendWebhook( + assert(process.env.color_webhook, "Missing color webhook env"), + "1227327297795657850", + [ + diffs.raw?.size && { + title: "Raw colors", + body: formatDiff(diffs.raw), + image: drawSections(await makeSections(diffs.raw)), + footer: makeFooter(diffs.raw.size, "raw color"), + key: "raw-colors", + }, + diffs.semantic?.size && { + title: "Semantic colors", + body: formatDiff(diffs.semantic), + image: drawSections(await makeSections(diffs.semantic)), + footer: makeFooter(diffs.semantic.size, "semantic color"), + key: "semantic-colors", + }, + ].filter((x) => !!x), + 0xd8ef9e, + ); + + if (diffs.icons?.size) + await sendWebhook( + assert(process.env.icons_webhook, "Missing icons webhook env"), + "1227327765079003217", + [ + { + title: "Icons", + body: formatDiff(diffs.icons), + image: drawSections(await makeSections(diffs.icons, true)), + footer: makeFooter(diffs.icons.size, "icon"), + key: "icons", + }, + ], + 0xf4b8f7, + ); + + if (diffs.code?.size) + await sendWebhook( + assert(process.env.code_webhook, "Missing code webhook env"), + "1233861867059941387", + [ + { + title: "Code", + body: formatDiff(diffs.code), + footer: makeFooter(diffs.code.size, "code"), + key: "code", + }, + ], + 0x86faf3, + ); +} diff --git a/src/update/utils.ts b/src/update/utils.ts new file mode 100644 index 00000000000..065a79af8f0 --- /dev/null +++ b/src/update/utils.ts @@ -0,0 +1,74 @@ +import { join as _join, dirname } from "node:path"; +import type { $ } from "bun"; + +export function assert(value: T | undefined | null, message?: string): T { + if (value === undefined || value === null) + throw new TypeError(message || `Failed to assert that ${value} is defined`); + return value; +} + +const numberSuffixRegex = /(.*?)(\d*)$/; +export function sortEntries(entries: T) { + return entries.sort(([a], [b]) => { + const [, _aKey, aNum] = a.match(numberSuffixRegex) || [], + [, _bKey, bNum] = b.match(numberSuffixRegex) || []; + const aKey = _aKey ?? a, + bKey = _bKey ?? b; + + if (aKey === bKey && aNum && bNum) return Number(aNum) - Number(bNum); + return aKey.localeCompare(bKey); + }); +} + +export function sortObj(obj: T) { + return Object.fromEntries(sortEntries(Object.entries(obj))) as T; +} + +// "you should make it uppercase" +const BYTE = 1024; +export function formatBytes(bytes: number, decimals = 1) { + if (!bytes) return "0 B"; + + const sizes = ["B", "KB", "MB"]; + const mag = Math.min(Math.floor(Math.log(bytes) / Math.log(BYTE)), sizes.length - 1); + return `${parseFloat((bytes / BYTE ** mag).toFixed(decimals))} ${sizes[mag]}`; +} + +export function handleShellErr(out: $.ShellOutput): $.ShellOutput { + if (out.exitCode !== 0 && out.exitCode !== 11) + throw new Error( + `${`${out.stdout.toString().trim()}\n${out.stderr.toString().trim()}`} (exit code ${out.exitCode})`, + ); + return out; +} + +export function formatError(e: any) { + return e?.stack ?? e?.message ?? String(e); +} + +export function listRequiredDirs(files: string[]) { + const dirs = [...new Set(files.map(dirname))].sort(); + + const required: string[] = []; + for (let i = 0; i < dirs.length; i++) { + if (i === dirs.length - 1 || !dirs[i + 1].startsWith(`${dirs[i]}/`)) { + required.push(dirs[i]); + } + } + return required; +} + +export function parseJsonl(text: string) { + const lines = text + .trim() + .split("\n") + .map((x) => x.trim()) + .filter((x) => !!x); + return JSON.parse(`[${lines.join()}]`); +} + +export function discordPath(path: string) { + return join("app", path); +} + +export const join = (...paths: string[]) => _join(...paths).replace(/\\/g, "/"); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000000..128870efae6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "types": ["bun"], + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": false, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + }, + "exclude": ["tmp"] +}