Repository navigation
feat(android): repack and sign APKs in TypeScript, and one repack entry for the Studio's Worker - #568
Merged
Merged
Conversation
…ry for the Studio's Worker
The Android repack runs with no SDK tool: the runtime build links one
unsigned template.apk with aapt2, and tools/repack/android.ts rewrites its
binary manifest and resource table (string pools sorted and renumbered as
aapt2 writes them), swaps the icons and the game's assets, aligns stored
entries as zipalign -p 4 does, and signs v1 + v2 with Web Crypto. The signer
is { privateKey, certificate }; a certificate other than the Pocket Studio
community certificate is refused unless the caller names it. aapt2, zipalign
and apksigner stay as the test oracle where the SDK is installed.
A segment of an app id that starts with a digit becomes `x` + the segment in
the Android package name.
tools/repack/index.ts gives the Studio one row per target (repack, runtime,
variantTargets, extension, contentType, signed); tests/repack-worker.test.ts
bundles it and every target's module for the browser and fails on any Node
or Bun API. tools/community-key.ts writes the community key as PKCS #8 and
X.509 DER files beside its keystore.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
doodlewind
added a commit
that referenced
this pull request
Oct 7, 2026
…eads to (#570) QuickJS's JS_Eval reads its input up to input[length], which must be '\0'. PocketActivity reads assets/app.js into a byte array without a terminator and copy_java_bytes copied exactly that many bytes, so the byte after the script was whatever the heap held. A Studio-made Package Check APK and the twenty48 remix APK from #568's runtime both stopped at boot on the Redmi 1S with a SyntaxError at the script's end; with the terminated copy the same game starts and plays. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 2 of server-side packaging for Pocket Studio: the Android repack runs with no SDK tool and signs in Web Crypto, every repack is checked to bundle for a Cloudflare Worker, and
tools/repack/index.tsis the one module the Studio imports.For the Studio:
tools/repack/index.tsruntimevariantTargetsextensioncontentTypesignedpsppsppsp.zipapplication/zipvitavitavita.vpkapplication/octet-stream3ds3ds3ds-dev.3dsxapplication/octet-streamipod-touchipodipodtouch4-dev.ipaapplication/octet-streamandroidandroidredmi-1s-dev,moto-g-play-dev.apkapplication/vnd.android.package-archiverepackTarget(name)finds a row by Studio target or runtime name.tools/repack.ts(the CLI) now calls the same rows; for Android it reads the community key's two DER files.The Android signer is the Pocket Studio community key.
REPACK_TARGETS.android.repackrefuses a certificate whose SHA-256 is notPOCKET_STUDIO_COMMUNITY_SIGNER(54c2abe6…dff6095c);repackAndroid(input, { certificateSha256 })takes another certificate only when the caller names it (the tests' throwaway keys). A Worker keeps the two files as secrets (base64) and passes{ privateKey: bytes, certificate: bytes }, or aCryptoKeyit imported once (importKey("pkcs8", …, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["sign"])). The repack verifies its v2 signature with the certificate's public key, so a key that is not the certificate's is refused.Android: template APK + TypeScript patch + Web Crypto signatures
Ported from
exp/android-pure-ts-repack(83fb0b8) onto main and made the default.bun tools/runtime.ts android):tools/android.ts runtimebuilds the libraries andclasses.dexinto a scratch directory,tools/android-link.tslinks them with aapt2 intotemplate.apk(identitydev.pocket-nexus.runtime.template, default icon, empty assets, unsigned). The runtime directory istemplate.apk+ the two templates it was linked from +runtime.json: 3 files, 1 759 075 bytes (was 10 files, 4 727 681). Built in 33.2 s at efb39e9.tools/repack/android.ts): keeps the template's entries in order, rewrites the compiledAndroidManifest.xml(package, versionCode, versionName) andresources.arsc(package name,string/app_name) intools/repack/shared/android-resources.ts, swaps icons andassets/app.js/assets/app.pak, aligns stored entries aszipalign -p 4does, and signs v1 + v2 intools/repack/shared/apk-sign.ts.tools/repack/android-native.tsis gone).Package name rule (for the Studio's docs)
Each
-becomes_; a dot-separated segment that then starts with a digit getsxin front.dev.pocket-nexus.studio.2pac.8-ball→dev.pocket_nexus.studio.x2pac.x8_ball. Each segment is then a letter followed by letters, digits and_; the name holds at most 127 characters (the resource table's limit), else the repack refuses. Two ids collide only when one has a segmentx<digit>…where the other has<digit>…(…studio.x2pacand…studio.2pac); the Studio can refuse handles and slugs that start withxand a digit, or checkandroidPackageName(id)(exported fromindex.ts) for uniqueness. versionCode stays major × 1 000 000 + minor × 1 000 + patch; the label is the title with ASCII whitespace trimmed and inner runs as one space (what aapt2 makes of a string resource), control characters refused.Other targets, for the same docs: the iPod bundle id is the app id unchanged (CFBundleIdentifier, and the URL scheme is the id lowercased); the Vita title id is
P+ the first 8 hex digits of SHA-256(app id), uppercase; the 3DS state folder issdmc:/pocketjs/runtime/apps/<first 16 lowercase hex digits of SHA-256(app id)>/state.The community key as a Worker secret takes it
bun tools/community-key.tsreads~/.config/pocket-nexus/signing/pocket-studio-community.p12with OpenSSL (-passin file:the.passwordbeside it; the password is on no command line and in no output, the key goes from OpenSSL's stdout to the file without being printed) and writespocket-studio-community.pkcs8.der(2 374 bytes) andpocket-studio-community.cert.der(1 343 bytes) beside it, mode 600. It writes nothing unless the certificate is54c2abe6…and the key signs what the certificate verifies, and it refuses a keystore whose name says release. Run once on this Mac. The Pocket Nexus release key was not read.Every repack runs in a Worker
tests/repack-worker.test.tsbundlestools/repack/index.tsand each ofpsp.ts,vita.ts,3ds.ts,ipod.ts,android.tswithBun.build({ target: "browser" }), fails on any Node or Bun built-in module (a resolve plugin overnode:*,bun:*andbuiltinModules) and on anyBun.,process.,require(,__dirname,import.meta.dir; a control case shows the check seesnode:fsandBun.file. It runs the bundled Android module and requires the same APK as the source module.@cloudflare/workerd-darwin-arm64,navigator.userAgent=Cloudflare-Workers) behind a fetch handler: Twenty48 TS Remix's APK with a 4096-bit throwaway key, 99–152 ms per request (3.97 MB request), passingapksigner verifyv1 + v2 andzipalign -c -p 4. Its uncompressed entries and icon pixels equal the Bun-made APK's; the deflate streams differ (each runtime'sCompressionStream), so byte-identical output holds within one JavaScript runtime.Verified
aapt2 oracle (
tests/repack-android.test.ts, SDK present): a corpus of six games (hyphens, digit segments,Tom's "Test" & <Game> 100%,@home \ ?, Japanese with U+3000, an emoji label, whitespace runs, versions 0.0.1 to 1999.999.999, with and without an icon) on the fixture template and ondist/runtime/android: the TypeScript APK holds the same entries in the same order with the same bytes outsideMETA-INF/as aapt2 + zipalign + apksigner's, passesapksigner verify(v1, v2, the test certificate) andzipalign -c -p 4, andaapt2 dump badgingreads the package and label.Redmi 1S (HM 1S, MIUI V5, Android 4.3 / SDK 18, adb f5f4c0ab), lease
android:f5f4c0abviatools/device-lease.ts. The adb server listed no device although the phone was on USB;adb kill-server+start-serverbrought it back (as #563 saw).twenty48copied from pocket-studio-uistarters/(not edited there) with iddev.pocket-nexus.studio.2048-remix→ packagedev.pocket_nexus.studio.x2048_remix(the digit rule, and no collision with Studio's release-signedtwenty48or feat(android): a generic runtime and a repack that signs games with the community key #563's community-signedtwenty48_remix, both left untouched), title "Twenty48 TS Remix", a 512 px two-colour icon.bun tools/repack.ts --target android … -o x2048-remix.apkwith the real community key: 186 ms, 1 928 590 bytes, SHA-25659d25415…0b860955.apksigner verify --print-certs: v1 true, v2 true,CN=Pocket Studio Community, O=Pocket Nexus Inc., C=US, SHA-25654c2abe6c53e5ead78b7d6c20ebcf23ed03b61d6d7ff081256e155fcdff6095c, no warnings;zipalign -c -p 4passes.adb install -r: Success in 7.5 s (one MIUI "Install" screen answered through uiautomator);dumpsys package: versionCode 1000, versionName 0.1.0, no DEBUGGABLE flag.am start: displayed in 503 ms, title card; tap to start and twelveinput swipes: tiles slide and merge, score 0 → 32. The launcher shows the scaled icon and the label; launching from the icon opens the game. No crash in logcat. Uninstalled afterwards; nothing of this PR is left on the phone.Timings (this Mac, Bun 1.3.14)
.pocket, community key (RSA 4096).pocket(5.8 MB).pocket(its footer hash)Tests
tests/repack-android.test.ts(34): package-name rule and refusals, versionCode, label, escaping; binary manifest/table read-back, rewrites that depend on the identity alone (A then B equals B), sorted pools, a label equal to a pool path sharing its entry, modified UTF-8; the APK's entries, contents, alignment, v1 + v2 verified by a separate TypeScript verifier (tests/helpers/apk-test-signer.ts), determinism from PKCS Track clean-main PocketJS naming cleanup #8 bytes and from aCryptoKey, the default icon; refusals (no signer, another certificate, a key that is not the certificate's, a bad key, no or a signed template, and phase 1's target/ABI/footer/file/viewport/icon/identity checks); the SDK-gated fixture check and corpus.tests/fixtures/repack-android/template.apk(6 438 bytes): aapt2's link ofhosts/android/appwith stand-inclasses.dexand libraries; the SDK-gated test relinks it and compares (POCKETJS_WRITE_ANDROID_FIXTURE=1rewrites it).tools/repack/shared/zip.ts:alignno longer forces an entry stored; it aligns whichever entry ends up stored.admitPocketencodes the thinned.pocketon first read ofbytes(Android never ships it).Not done here
.pocketfooter hash costs 207 ms on a 5.8 MB package (contracts/spec/pocket-package.ts); a faster FNV implementation would cut every target's repack in a Worker. Left for a separate change.--profile=moto-g-play) links its template the same way; it was not built or run on a phone here.🤖 Generated with Claude Code