Compare commits

...

3 Commits

Author SHA1 Message Date
e65591431c Stop device:launch crying wolf on release builds
A release APK is not DEBUGGABLE, so Capacitor never enables web-contents
debugging and the devtools socket is never opened. page_loaded waited for that
socket regardless, so it reported "renderer denied" three times for a healthy
app, force-stopping and restarting it on each retry. It now branches: debug
builds still wait for the page to parse; release builds check the process
survived with no denial against our package, and say the page load is assumed
rather than claiming a verification that is not possible over adb.

The failure diagnostic was unfiltered and unbounded, so it printed other apps'
denials from minutes earlier. It is now scoped to our package and to the
current launch.

Also fixes keep-awake, which still ran the "usb" form that CLAUDE.md had
already recorded as ineffective on this AC-charging phone, and corrects the
documented value: "true" measures as 15 here, not 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:56:59 +03:00
1d2cbda50d Ship a signed release APK
The release build type reuses the debug signing config, so a downloaded APK
installs over an existing one instead of demanding an uninstall. This never
goes to Play, which rejects debug keys anyway, so there is no private key
worth safeguarding for years and no way to strand anyone on a version that
can no longer be updated.

That choice is load-bearing in one direction: a release build is not
DEBUGGABLE, so adb shell run-as stops reading app data back out, and there is
no in-app restore. A signature change would have cost the drill progress.

npm run release stages release/404-Layers-<version>.apk with a checksum and
verifies the signature, since an unsigned APK builds fine and only fails at
install time, on someone else's phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:51:01 +03:00
09feb899d6 Drop the Design section from the README
It described the visual language rather than telling a reader anything
they need in order to build, run or understand the app. The fonts it
mentioned are still covered by public/fonts/README.md and the licence
section, so nothing is orphaned by removing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:35:36 +03:00
7 changed files with 171 additions and 25 deletions

3
.gitignore vendored
View File

@@ -20,6 +20,9 @@ android/local.properties
android/.idea/ android/.idea/
*.apk *.apk
*.aab *.aab
# Staged release artifacts. The APK is covered above; this also catches the
# checksum file that sits beside it.
release/
# No exception for debug.keystore: an earlier "!debug.keystore" here deliberately # No exception for debug.keystore: an earlier "!debug.keystore" here deliberately
# un-ignored it, which would have committed a signing key silently had one ever # un-ignored it, which would have committed a signing key silently had one ever
# landed in the tree. Signing keys belong in ~/.android, not in the repo. # landed in the tree. Signing keys belong in ~/.android, not in the repo.

View File

@@ -76,7 +76,7 @@ Device notes), but nothing on screen can be verified through a lockscreen and
| Command | What it does | | Command | What it does |
|---|---| |---|---|
| `npm run device:keep-awake` | Stay awake while charging. Do this at the start of a session. | | `npm run device:keep-awake` | Stay awake while charging. Do this at the start of a session. |
| `npm run device:launch` | Check awake/unlocked, force-stop, start, wait for the page to actually parse | | `npm run device:launch` | Check awake/unlocked, force-stop, start, wait for the page (debug builds) |
| `npm run device:awake` | Just the check — prints a loud warning if locked | | `npm run device:awake` | Just the check — prints a loud warning if locked |
| `npm run device:sleep-normal` | Revert the stay-awake setting when finished | | `npm run device:sleep-normal` | Revert the stay-awake setting when finished |
@@ -84,6 +84,22 @@ Device notes), but nothing on screen can be verified through a lockscreen and
starts fine even when the renderer was denied. On failure it prints the starts fine even when the renderer was denied. On failure it prints the
`AppBootManager` denial lines rather than leaving a blank window to puzzle over. `AppBootManager` denial lines rather than leaving a blank window to puzzle over.
**On a release build it cannot do that, and says so.** A release APK is not
`DEBUGGABLE`, so Capacitor never calls `setWebContentsDebuggingEnabled` and
`webview_devtools_remote_<pid>` is never opened — the page simply cannot be
inspected over adb. Demanding that socket anyway made the helper report
`renderer denied` three times for a perfectly healthy app, force-stopping and
restarting it each time. It now checks that the process survived and that
nothing denied *our* renderer, then prints `page load assumed, not verified`. A
blank grey window and a working one are genuinely indistinguishable from here,
so that wording is the honest limit, not hedging.
The denial grep is filtered by package and bounded to the current launch
(`logcat -d -T`, timestamp from `adb shell "date +'%m-%d %H:%M:%S.000'"` — the
format must be one quoted argument or toybox `date` rejects it). Unfiltered, it
printed `com.xiaomi.wearable` denials from minutes earlier and sent one session
chasing a denial that had never happened to us.
It also reads the pid *after* starting the app. `adb install -r` kills the It also reads the pid *after* starting the app. `adb install -r` kills the
running process, so a pid captured earlier points at a dead process and the running process, so a pid captured earlier points at a dead process and the
devtools socket lookup fails while the app itself is healthy — that produced one devtools socket lookup fails while the app itself is healthy — that produced one
@@ -172,9 +188,29 @@ now empty and bar styling is configured in `capacitor.config.json`
**No community plugins.** Only first-party: `@capacitor/core`, `/cli`, **No community plugins.** Only first-party: `@capacitor/core`, `/cli`,
`/android`, `/haptics`, `/preferences`, `/app`. `/android`, `/haptics`, `/preferences`, `/app`.
**Debug signing.** The debug keystore is stable per-machine, so Phase 2's APK **Debug signing, releases included.** The debug keystore is stable per-machine,
installs over Phase 1's without an uninstall and drill progress survives. so Phase 2's APK installs over Phase 1's without an uninstall and drill progress
`appId` is `dev.ncode.sp404layers` and must not change for the same reason. survives. `appId` is `dev.ncode.sp404layers` and must not change for the same
reason.
The `release` build type reuses that key on purpose, via the plugin's built-in
`signingConfig signingConfigs.debug`, so no keystore path or password lands in a
tracked file. This only ever ships as a sideloaded APK, never to Play — which
rejects debug keys anyway. The trade: anyone can build an APK that upgrades over
it, against which there is no key to keep safe for years and no way to strand
anyone on a version that can no longer be updated.
Verified rather than assumed: the release APK's signer digest is
`2494c983…551cc`, identical to every debug build, so it installs in place. That
matters more than it looks, because a release build is **not `DEBUGGABLE`** — the
`adb shell run-as` trick that reads `CapacitorStorage.xml` back out stops working
the moment one is installed, and there is no in-app restore to fall back on. A
signature change would therefore have cost the drill progress outright.
`npm run release` (`scripts/release.mjs`) builds it, stages
`release/404-Layers-<version>.apk` with a `.sha256` beside it, and fails loudly
if apksigner reports no certificate: an unsigned APK builds happily and only
fails at install time, on someone else's phone. `release/` is gitignored.
## Phase 2: the React port ## Phase 2: the React port
@@ -187,6 +223,7 @@ kit — the app is four tabs and a reducer, and none of those earn their keep.
| `npm run test:watch` | Watch mode | | `npm run test:watch` | Watch mode |
| `npm run typecheck` | `tsc --noEmit` | | `npm run typecheck` | `tsc --noEmit` |
| `npm run extract:data` | Regenerate the combo table from `reference/` | | `npm run extract:data` | Regenerate the combo table from `reference/` |
| `npm run release` | Build the signed release APK into `release/`, with checksum |
``` ```
src/domain/ src/domain/
@@ -245,8 +282,10 @@ beats an afternoon of synthetic input.
**Stay awake needs `svc power stayon true`, not `usb`.** The `usb` form writes **Stay awake needs `svc power stayon true`, not `usb`.** The `usb` form writes
`stay_on_while_plugged_in = 2`, and this phone charges over AC (`USB powered: `stay_on_while_plugged_in = 2`, and this phone charges over AC (`USB powered:
false`), so it silently never engages — `mStayOn` stays false. `true` writes 7 false`), so it silently never engages — `mStayOn` stays false. `true` sets every
(any source) and works. The screen timeout is separate and was 30s: charging-source bit and works; measured as **15** here (AC|USB|wireless|dock),
not the 7 an earlier note claimed — 7 predates dock charging. The screen timeout
is separate and was 30s:
``` ```
adb shell svc power stayon true adb shell svc power stayon true

View File

@@ -17,6 +17,25 @@ with what it does on that layer. Then it drills you on them.
Left: sample mode, nothing held — three functions available. Right: the same Left: sample mode, nothing held — three functions available. Right: the same
screen with `SHIFT` held — thirty-five, and every pad now shows what it becomes. screen with `SHIFT` held — thirty-five, and every pad now shows what it becomes.
## Install
Download the APK from the [latest release](https://git.jawhng.xyz/jawhng/404-Layers/releases),
open it on your phone, and allow installing from unknown sources when Android
asks. It needs **Android 7.0 or newer** and is about 3 MB.
It isn't on the Play Store, so Android will warn you it came from outside the
store — that warning is about where the file came from, not what's in it. Two
things worth knowing before you trust a stranger's APK:
- It's signed with a **debug key**, not a private one. Nothing here is worth
protecting with a key I'd have to keep safe for years, and it means a lost
keystore can never strand you on a version that can't be updated.
- It asks for `INTERNET` and `VIBRATE`. The vibration is the drill feedback;
the internet permission is Capacitor's default and the app never uses it —
there are no network calls in the source, and everything it needs is bundled.
Each release lists a `sha256` if you want to check the download.
## What it does ## What it does
| Tab | | | Tab | |
@@ -32,8 +51,9 @@ the combos you keep missing keep coming back. Progress is kept on the device.
## Status ## Status
It works and it's in daily use on one phone. It is **not** on any app store: it's It works and it's in daily use on one phone. It is **not** on any app store:
a debug-signed APK, sideloaded over `adb`. it's a sideloaded APK, either from the release page above or built from source
below.
It was built in two passes, and both are still in the repo: It was built in two passes, and both are still in the repo:
@@ -70,6 +90,7 @@ npm run dev # http://localhost:5173
| Command | | | Command | |
|---|---| |---|---|
| `npm run release` | Build the signed release APK into `release/`, with its checksum |
| `npm test` | The domain suite | | `npm test` | The domain suite |
| `npm run typecheck` | `tsc --noEmit` | | `npm run typecheck` | `tsc --noEmit` |
| `npm run extract:data` | Regenerate the combo table from `reference/` | | `npm run extract:data` | Regenerate the combo table from `reference/` |
@@ -127,17 +148,6 @@ testable, and immediately surfaced a real bug in the weighted pick.
`CLAUDE.md` holds the fuller engineering notes, including a number of `CLAUDE.md` holds the fuller engineering notes, including a number of
Android-specific traps that cost real time. Android-specific traps that cost real time.
## Design
The look follows Teenage Engineering's EP-133 manual: flat fills, heavy outlines,
hard zero-blur shadows, one orange accent that means exactly one thing (this
control is live), and monospaced technical annotation. The panel is laid out from
the hardware — the control groups carry the same silkscreen captions as the
device, and the screen is a circular housing behind a rectangular LCD.
Type is Space Grotesk and Space Mono, self-hosted so the app works with no
network.
## Data ## Data
The combo data is derived from the SP-404MK2 reference manual (v5.50): control The combo data is derived from the SP-404MK2 reference manual (v5.50): control

View File

@@ -8,7 +8,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 1
versionName "1.0" versionName "1.0.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -20,6 +20,11 @@ android {
release { release {
minifyEnabled false minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
// Debug key on purpose: this only ever ships as a sideloaded APK, never
// to Play. Same signature as every build so far, so a release installs
// over an existing one instead of demanding an uninstall. Using the
// built-in config keeps the path and password out of this file.
signingConfig signingConfigs.debug
} }
} }
} }

View File

@@ -8,8 +8,11 @@
"sync:www": "bash scripts/sync-www.sh", "sync:www": "bash scripts/sync-www.sh",
"sync": "npm run build && npx cap sync android", "sync": "npm run build && npx cap sync android",
"apk": "npm run sync && cd android && ./gradlew assembleDebug", "apk": "npm run sync && cd android && ./gradlew assembleDebug",
"apk:release": "npm run sync && cd android && ./gradlew assembleRelease",
"apk:phase1": "npm run sync:www && npx cap sync android && cd android && ./gradlew assembleDebug", "apk:phase1": "npm run sync:www && npx cap sync android && cd android && ./gradlew assembleDebug",
"install:apk": "adb install -r android/app/build/outputs/apk/debug/app-debug.apk", "install:apk": "adb install -r android/app/build/outputs/apk/debug/app-debug.apk",
"install:release": "adb install -r android/app/build/outputs/apk/release/app-release.apk",
"release": "node scripts/release.mjs",
"device:awake": "bash scripts/device.sh awake", "device:awake": "bash scripts/device.sh awake",
"device:keep-awake": "bash scripts/device.sh keep-awake", "device:keep-awake": "bash scripts/device.sh keep-awake",
"device:sleep-normal": "bash scripts/device.sh sleep-normal", "device:sleep-normal": "bash scripts/device.sh sleep-normal",

View File

@@ -49,7 +49,10 @@ ensure_awake() {
} }
keep_awake() { keep_awake() {
"$ADB" shell svc power stayon usb >/dev/null # `usb` writes stay_on_while_plugged_in=2 (USB only) and this phone charges
# over AC, so it silently never engages. `true` sets every charging-source bit:
# measured as 15 here (AC|USB|wireless|dock), not the 7 that older notes claim.
"$ADB" shell svc power stayon true >/dev/null
echo "stay-awake while charging: ON (revert with: scripts/device.sh sleep-normal)" echo "stay-awake while charging: ON (revert with: scripts/device.sh sleep-normal)"
} }
@@ -58,6 +61,28 @@ sleep_normal() {
echo "stay-awake: reverted to system default" echo "stay-awake: reverted to system default"
} }
# Release builds are not DEBUGGABLE, so Capacitor never calls
# setWebContentsDebuggingEnabled and the devtools socket is never opened. Asking
# for it anyway reports "renderer denied" for a perfectly healthy app.
is_debuggable() {
"$ADB" shell dumpsys package "$PKG" | grep -q DEBUGGABLE
}
# Device-side clock, for bounding logcat to this launch. The format has to be
# quoted as one argument: toybox date rejects a bare `+FMT` after `adb shell`.
device_now() {
"$ADB" shell "date +'%m-%d %H:%M:%S.000'" | tr -d '\r'
}
# Did the renderer get denied since $1? These lines are emitted whether or not
# the build is debuggable, and the package filter matters - other apps are
# denied constantly and their lines mean nothing here.
denied_since() {
"$ADB" logcat -d -T "$1" 2>/dev/null \
| grep -E "AppBootManager.*Caller\[$PKG\]|cr_ChildProcessConn: Failed" \
| grep -v 'Result\[true\]'
}
# Wait until the page is actually parsed, not merely until the activity is up: # Wait until the page is actually parsed, not merely until the activity is up:
# the activity starts fine even when the renderer was denied. # the activity starts fine even when the renderer was denied.
page_loaded() { page_loaded() {
@@ -67,6 +92,19 @@ page_loaded() {
# app itself is healthy - that produced one false "page never loaded". # app itself is healthy - that produced one false "page never loaded".
pid=$("$ADB" shell pidof "$PKG" | tr -d '\r') pid=$("$ADB" shell pidof "$PKG" | tr -d '\r')
[ -n "$pid" ] || return 1 [ -n "$pid" ] || return 1
# Release build: the page cannot be inspected from here at all, so prove what
# can be proved - the process survived and nothing denied its renderer. A
# blank grey window is indistinguishable from a working one over adb, which is
# why this says "assumed" rather than claiming the page loaded.
if ! is_debuggable; then
sleep 2
[ -n "$("$ADB" shell pidof "$PKG" | tr -d '\r')" ] || return 1
[ -z "$(denied_since "$1")" ] || return 1
echo "app running (pid $pid, release build: page load assumed, not verified)"
return 0
fi
for i in $(seq 1 40); do for i in $(seq 1 40); do
sock=$("$ADB" shell cat /proc/net/unix | grep -o "webview_devtools_remote_$pid" | head -1) sock=$("$ADB" shell cat /proc/net/unix | grep -o "webview_devtools_remote_$pid" | head -1)
if [ -n "$sock" ]; then if [ -n "$sock" ]; then
@@ -82,19 +120,23 @@ page_loaded() {
launch() { launch() {
ensure_awake || return 1 ensure_awake || return 1
local attempt local attempt started
for attempt in $(seq 1 $ATTEMPTS); do for attempt in $(seq 1 $ATTEMPTS); do
started=$(device_now)
"$ADB" shell am force-stop "$PKG" "$ADB" shell am force-stop "$PKG"
"$ADB" shell am start -W -n "$ACT" >/dev/null "$ADB" shell am start -W -n "$ACT" >/dev/null
if page_loaded; then if page_loaded "$started"; then
[ "$attempt" -gt 1 ] && echo "(took $attempt attempts - post-install renderer denial)" [ "$attempt" -gt 1 ] && echo "(took $attempt attempts - post-install renderer denial)"
return 0 return 0
fi fi
echo "attempt $attempt: renderer denied, retrying" >&2 echo "attempt $attempt: no page, retrying" >&2
done done
echo "ERROR: page never loaded after $ATTEMPTS attempts." >&2 echo "ERROR: page never loaded after $ATTEMPTS attempts." >&2
"$ADB" logcat -d | grep -E 'AppBootManager.*Result\[false\]|ChildProcessConn: Failed' | tail -4 >&2 # Bounded to this launch and to our package. Unfiltered, this printed other
# apps' denials from minutes earlier and sent one debugging session chasing a
# denial that had never happened to us.
denied_since "$started" | tail -4 >&2
return 1 return 1
} }

44
scripts/release.mjs Normal file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env node
/**
* Builds the release APK and stages it under release/ under a name a stranger
* can recognise, next to the checksum to verify a download against.
*
* It verifies the signature rather than trusting that the signing config took:
* an unsigned APK builds happily and only fails at install time, on someone
* else's phone.
*/
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
execFileSync("npm", ["run", "apk:release"], { cwd: root, stdio: "inherit" });
const built = join(root, "android/app/build/outputs/apk/release/app-release.apk");
const name = `404-Layers-${version}.apk`;
const out = join(root, "release", name);
mkdirSync(dirname(out), { recursive: true });
copyFileSync(built, out);
const bytes = readFileSync(out);
const sha = createHash("sha256").update(bytes).digest("hex");
writeFileSync(`${out}.sha256`, `${sha} ${name}\n`);
// build-tools is pinned in CLAUDE.md; a missing apksigner should fail loudly.
const sdk = process.env.ANDROID_HOME ?? join(process.env.HOME ?? "", "Android/Sdk");
const certs = execFileSync(join(sdk, "build-tools/36.1.0/apksigner"), ["verify", "--print-certs", out], {
encoding: "utf8",
});
const digest = certs.match(/certificate SHA-256 digest: (\w+)/)?.[1] ?? "";
if (digest === "") throw new Error(`apksigner reported no certificate:\n${certs}`);
console.log(`\n${name} ${(bytes.length / 1e6).toFixed(2)} MB`);
console.log(`file sha256 ${sha}`);
console.log(`cert sha256 ${digest}`);
console.log(`\nStaged at ${out}`);