mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:27:02 +01:00
82e206116a
873 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 82e206116a |
Make the geofence tell the truth, and the phone explain itself
The enforcer failed in both directions: nothing blocked at home, and everything blocked at the office. The comparison was never inverted -- `metres <= threshold` was always right. Three separate causes: Only ACCESS_COARSE_LOCATION was declared. Android 12+ fuzzes coarse to a ~1-2 km grid against a 150 m fence, so a fix taken at home routinely landed outside it, read as AWAY, and restored every app the geofence exists to hide. FINE is now declared and self-granted by the device owner, along with coarse and background, re-asserted every pass so a reinstall cannot silently drop it. There were three states but two behaviours. LOCATION_UNKNOWN applies the identical sweep to AT_HOME -- correct fail-closed design, indistinguishable from outside. And nothing ever requested a fix: the pass read whatever another app happened to cache, so at the office with no navigation running the cache was empty. Passes now acquire a fix inside a location-typed foreground service, bounded at 20 s, and treat anything older than 30 min as no fix. That window is deliberately longer than the 15-minute cadence; a shorter one marks the newest cache stale on every pass of an idle phone and blocks everything. None of it was diagnosable. `adb logcat -d -s FocusOwner:V` came back empty while dumpsys showed 82 alarms had fired, and `run-as` is refused on the release build device owner requires. So the pass now writes a ring-buffered JSONL record read back in-app: reason, distance vs the fence, fix age/provider/accuracy, curfew state, and every hidden app with the rule that hid it. Coordinates are never recorded -- a distance is enough to debug a geofence, and this is a home address. Also fixed along the way: - Hysteresis keyed off `isEnforcing`, which is true under AWAY too because YouTube stays hidden, so the threshold was permanently radius+hysteresis. It now keys off whether the previous pass was inside the fence. - The scheduler's curfew-boundary alarms were documented but never scheduled; both callers invoked `scheduleNext()` bare, so 05:00 could land 15 min late. - Installing over the app cancelled its pending alarms, so `adb install -r` silently stopped enforcement until someone opened the app. MY_PACKAGE_REPLACED now re-arms it, verified on device without launching the app. - A wrong-typed log record threw instead of being skipped, and the exception escaped far enough to leave the page stuck busy -- which disables the release-device-owner button, the one control that must work when everything else has gone wrong. - `lib/enforcement.dart` was a second decision layer with no always-blocked concept, cited by KDoc as pinned by a `PolicyParityTest` that never existed. It would have made the new screen report YouTube visible while it is hidden. Deleted; the UI renders the Kotlin record rather than re-deciding. - Anchoring home accepted a stale or unknown-precision fix. A wrong home offsets the fence everywhere and forever, which is what produced a 766 m reading while standing at home. Policy: Play Store is allowed by day (not during curfew) so infakt can be installed and updated; the sweep is default-deny for third-party packages, so anything installed from Play is hidden on the next at-home pass. Tachiyomi's per-source extension apks are covered by a new prefix allowlist rather than an exact list that goes stale whenever an extension is installed. `export.py` stopped subtracting the allowlist from `blockable_system_packages`. Sweepable is not hideable: that list decides which system packages are eligible for a decision, and `isAllowed` is what protects them. Removing an allowed system package from the sweep freezes it in its last state -- it would have stranded Play hidden permanently the moment it was allowlisted. Verified on the Pixel 6a: FINE granted POLICY_FIXED, `applied AT_HOME` with a 0 m distance from a +/-11 m GPS fix, YouTube/Chrome/Firefox absent from `pm list packages -e`, Play Store, infakt, RunnerUp and all five Tachiyomi packages present. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HjxL9kdAzZmGqQA5tMNrZ8 |
|||
| 47371ac9a1 |
Wire uBlock Origin into every real Chromium launch, alongside LeechBlock
install_ublock.sh patches the same wrapper install_leechblock.sh already targets, always rewriting --load-extension into a single comma-joined flag rather than appending a second occurrence — verified empirically that Chromium is last-wins across separate --load-extension flags and would otherwise silently drop LeechBlock. seed_ublock_storage.js previously hardcoded uBlock Origin's Chrome Web Store extension ID, but --load-extension (unpacked) loads get a synthetic, install-path-derived ID instead. It now reads the real ID back out of the profile's own Preferences file, with a --ext-path override for installers whose package path differs from Arch's. install_ungoogled_chromium_ubuntu.sh's UBLOCK_EXT_PATH was an unverified guess (/usr/share/webext/ublock-origin-chromium); corrected to the real Debian/Ubuntu package path confirmed via packages.ubuntu.com. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015yfDYbaw1D1WhVMqWhsSDA |
|||
| 0c28f9988f |
Fix pacman whitelist matching for yay's -U package-file installs
is_blocked_package_name compared raw pacman -U <file>.pkg.tar.zst arguments against whitelist entries, which never matched since yay always installs AUR packages this way. Added strip_pkgfile_suffix() to derive the bare package name first, plus a regression test. Also swaps the browser whitelist entries to ungoogled-chromium (Chromium 151 dropped MV2, breaking uBlock Origin's full/classic build). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015yfDYbaw1D1WhVMqWhsSDA |
|||
| a97ad66e1f |
Whitelist RunnerUp day and night, so it can always sync
RunnerUp needs to stay enabled after a run to export the TCX file screen-locker verifies against; being disabled by focus mode at home silently blocked that. It's straightforwardly beneficial, so treat it like the other always-available apps: present in both WHITELIST and NIGHT_WHITELIST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013SH7XBkpEqQX4KghHdgx9T |
|||
| 2d4d2a2eb2 |
Block by hiding apps, which the phone cannot undo
The network layer was the wrong place for this. Tested remotely today: opening RethinkDNS and switching off its device blocklists un-blocked YouTube in a few taps, and no device owner API can stop that -- an on-device filter app always owns its own rules. App hiding is different. 'pm unhide' from adb shell throws a SecurityException and the package stays hidden; only the device owner can reverse it, and reaching that costs a factory reset. So the block moves there, scoped to an explicit allowlist and the home geofence. Both whitelists are rewritten from the apps that actually exist on this phone. The old ones described the rooted Blackview. Every browser is excluded now, so at home a link does not open at all -- confirmed as intended. LAUNCHER_PACKAGE was still the Blackview's launcher, which is not installed here. The exporter refused to render the policy twice over it -- precisely what that check is for. The network layer stays a soft deterrent by choice: pinned and uninstall-blocked, but without lockdown, and its blocklists remain switchable. Private DNS is implemented but ships disabled. stunnel logged zero connections from the handset across two pin attempts, so the router does not forward 853. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014DqEd1uUhyT52KjaNeLanC |
|||
| 62862c01ab |
Close the two holes that made the network block advisory
Both were measured, not theorised. `pm uninstall --user 0 com.celzero.bravedns` succeeded while the VPN was pinned and DISALLOW_CONFIG_VPN was set -- I ran it to check whether the provider was protected, and it removed the app, took the DNS filter with it, and left the always-on pin aimed at a package that no longer existed. The second hole was quieter: without lockdown, stopping the tunnel let traffic flow normally, so the filter only held while it was running. So the provider now gets setUninstallBlocked, re-asserted every pass, and the pin runs with lockdown. Lockdown is a real hazard and is deliberate: if the VPN app breaks, the device has no connectivity until ownership is released. That is only survivable because the release path lives in the enforcer app and needs no network. releaseDeviceOwner reads the pinned package before unpinning it, then unblocks its uninstall. Reversed, getAlwaysOnVpnPackage returns null after the unpin and the provider would stay unremovable forever -- the same trap the self-uninstall ordering already avoids. Home coordinates are provisioned. The pass now logs "applied AT_HOME" rather than LOCATION_UNKNOWN, so enforcement is finally geofenced rather than permanent-everywhere, driven by a real fix. `pm uninstall --user 0` on the provider now returns DELETE_FAILED_OWNER_BLOCKED. What this does NOT fix: RethinkDNS's blocklists are gone with the uninstall, since they live in app-private storage. The filter is running with no rules and youtube.com resolves normally until they are re-entered. And nothing here stops those rules being switched off from inside the app -- no device owner API can. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014DqEd1uUhyT52KjaNeLanC |
|||
| 6836a32b43 |
Let the app set its own home, and lock VPN configuration
Two gaps left from the device owner work. The geofence had no way to be provisioned. push_home_location.sh stages the file through `run-as`, which returns "package not debuggable" on the release build device owner requires, and making the build debuggable to fix that would hand anyone with adb the ability to edit this app's state -- the bypass the whole design exists to close. So the app writes its own filesDir instead, from a button. Deliberately not an exported intent: anything that can set home from outside the app can set it somewhere else, and the geofence would then report AWAY forever with enforcement off. The coordinates are never logged either, since that is a home address and logcat is readable by anyone with adb. DISALLOW_CONFIG_VPN is now applied from the app, after the filter was confirmed working rather than before -- locking VPN configuration over a broken setup would pin the broken state in place. The open question was whether it also freezes RethinkDNS's own settings; it does not. Its blocklist selection was changed under the restriction without trouble, which is the answer that matters, because the alternative meant every domain rule had to be final before locking. The status page gained a Geofence row. "no home set - enforcing everywhere" is a large behavioural difference from a working geofence and was previously visible only by inspecting logcat. The widget tests caught a real bug rather than a test-only one: the two new buttons overflowed the column by 4px, and the control that must never be clipped off the bottom of the screen is "Release device owner". The body scrolls now. Verified after a reboot: youtube hidden, all 14 youtube domains at 127.0.0.1, google/gmail/play resolving normally, no_config_vpn still enforced, uninstall still refused. The geofence itself stays untested on hardware until the phone has a GPS fix to record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014DqEd1uUhyT52KjaNeLanC |
|||
| db1d8f8247 |
Pin RethinkDNS as always-on VPN, the layer that reaches every app
Hiding the YouTube package stops the app. It does nothing about youtube.com in Firefox, in a webview, or in any client nobody has enumerated. This is that layer. setAlwaysOnVpnPackage from the device owner is not the mechanism that failed on 2026-08-09 -- that was `settings put secure always_on_vpn_app`, which writes successfully, survives a reboot, and is ignored by VpnManager. The DPM call takes effect: dumpsys now reports mAlwaysOnVpnPackage=com.celzero.bravedns. Lockdown stays off. It drops all traffic whenever the tunnel is down, and the wipe checklist names it together with the DISALLOW_* restrictions as the combination most likely to leave an unrecoverable device. DISALLOW_CONFIG_VPN is deferred for the same reason: locking VPN configuration before the filter is confirmed working would pin a broken setup in place. The exporter emits always_on_vpn_package only when the provider is also in never_disable_prefixes, and RethinkDNS was added there. Pinning a package the sweep can hide is the worst case -- the restriction would point at a hidden app and cost general connectivity, not just the filter. The pin is re-asserted every pass and, unlike the uninstall block, does not track the decision: the network filter must not lift when the geofence says AWAY. releaseDeviceOwner now unpins the VPN and clears the restriction before clearing ownership, for the same reason it already unblocked uninstall. Not yet active, and the commit does not pretend otherwise: the tunnel needs a one-time in-app VPN consent tap that adb cannot grant, and RethinkDNS is at first-run state because its wildcard rules live in app-private storage that the backup never reached. They have to be re-entered by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014DqEd1uUhyT52KjaNeLanC |
|||
| b1de6f8d38 |
Take the geofence out of YouTube's reach, and pin infakt on
Leaving the house was an off switch. The AWAY branch returned early with packagesToShow = every installed package, so walking 150m from home restored YouTube -- which is the exact affordance device owner was provisioned to remove. always_blocked_packages is exempt from all four branches: AWAY hides them, the allowlist loop cannot show them, and the workout release skips them, because otherwise "go for a run" is the off switch instead. Chrome is included as a second route to the same content; Firefox stays, and carries the uBlock filters. Play is deliberately left geofenced so apps can still be installed away from home -- and a hidden package cannot be reinstalled from Play anyway. infakt moves from the allowlist to SYSTEM_NEVER_DISABLE. Allowlisting only held while it stayed in BOTH the day and night lists, and dropping it from either would silently make it hideable; isAllowed() consults the never-disable list before the curfew split, so it now holds under every condition. It is device-paired to a bank over SMS, so losing it strands the same re-authentication chain a hidden Messages app would. The exporter now refuses an always-blocked package that is not also blockable: the runner filters unopted system packages out of installedPackages, so such an entry would never be hidden and nothing would say so. Verified: 128 Python tests at 100% branch coverage, 16 Kotlin tests, the new ones canary-checked by breaking an assertion and confirming the suite actually fails. On device, youtube/music/chrome/vending are hidden=true, infakt hidden=false, uninstall still refused. The AWAY path itself is unit-tested but NOT verified on hardware: with no home_location.json every pass decides LOCATION_UNKNOWN, so the branch is unreachable on this phone until coordinates can be provisioned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014DqEd1uUhyT52KjaNeLanC |
|||
| 6bf7343c0f |
Make the enforcer real: block YouTube, and refuse to be uninstalled
The device owner is provisioned, so enforcement stops being theoretical. Three things had to be true before it was safe to switch on. Release signing. A device owner can only be replaced by an APK with the same signature, and `dpm remove-active-admin` does not work on one, so a debug-signed device owner could never be updated -- the only exit would be another factory reset. focus_owner now uses the shared key.properties config the other apps use. No minify: an untested ProGuard pass that strips a DevicePolicyManager callback produces a device owner that cannot release itself. Uninstall blocking. Device owner hides Settings' uninstall button, but `adb uninstall` still worked, and that is the route that strands ownership with no holder. setSelfUninstallBlocked closes it, re-asserted every pass so it self-heals. The ordering in releaseDeviceOwner is not stylistic: unblock, THEN clear. Reversed, the block outlives the ownership required to lift it. The allowlist. config.sh describes the rooted phone, so the hide set was computed on the PC before any pass ran -- which caught pl.infakt.infakt in it. infakt is device-paired to a bank over SMS, so hiding it would have stranded the same re-authentication chain as hiding Messages. Verified on the Pixel 6a as a real device owner: hid 27, and youtube, youtube.music, chrome, vending and videos are all hidden=true, while phone, messages, keepass, mbank, revolut, infakt, mobywatel, settings and signal stay hidden=false. `adb uninstall` returns DELETE_FAILED_DEVICE_POLICY_MANAGER. All of it survives a reboot, which is exactly what `pm suspend` and `pm disable-user` failed to do here. restore_apps.sh reinstalls the 54 backed-up packages via install-multiple, since split APKs make a bare `adb install` fail. It skips the device owner's own package by name. Enforcement is permanent for now, not geofenced: push_home_location.sh delivers coordinates through `run-as`, which is dead on a release build, so every pass decides LOCATION_UNKNOWN and fails closed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014DqEd1uUhyT52KjaNeLanC |
|||
| eeaf9590da |
Let the sweep hide preinstalled apps, one named package at a time
The Device Owner sweep skipped every FLAG_SYSTEM package, which meant it could not touch the apps it exists to block: YouTube ships at /product/app/YouTube as a system app, and so does Chrome. Deleting the filter is not the fix. Measured on the Pixel 6a, that would expose 320 system packages of which 243 match no allowlist entry and no never_disable_prefix -- including com.android.cellbroadcastreceiver (emergency alerts), com.android.credentialmanager and com.android.devicelockcontroller. So system apps stay default-deny and blockable_system_packages is the opt-in. com.android.vending is on the list deliberately: leaving Play reachable makes every other removal a one-tap undo. The list lives in the Python exporter rather than config.sh because it is a safety boundary, and a shell typo should not be able to widen it. The exporter subtracts anything already allowed, so an asset can never state that a package is both allowed and blockable. Kotlin reads the field with a new optionalStringSet: an asset predating the field parses to the empty set instead of throwing, because a throw makes decide() skip the pass and silently disable enforcement. Verified on-device under a profile owner: hid 1 for YouTube, restored 1 when temporarily allowed, and cellbroadcastreceiver, credentialmanager, devicelockcontroller and settings all left hidden=false. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014DqEd1uUhyT52KjaNeLanC |
|||
| 553c395cff |
inFakt is SMS-only 2FA, so the wipe cannot strand the account
This was the last blocker on the Device Owner decision. Checked in the
web panel: "Aplikacja weryfikacyjna" is unconfigured, so no TOTP seed
exists for a wipe to destroy, and only "Wrazliwe" actions are gated --
"Logowanie" is unchecked, so plain login needs the password alone. The
registered SMS number lives on the SIM, which survives.
The browser-login approval tap is a Firebase push prompt
(FirebaseInstanceIdReceiver on the device). Push tokens are per-install,
so a reinstall re-registers; and if that ever misbehaved, SMS is still
the fallback.
Corrects the wipe checklist, which assumed TOTP secrets were "likely"
inside the KeePassDX database and treated the .kdbx as a seed backup.
Neither holds: the canonical .kdbx is on the PC, and there are no seeds.
Earlier advice in this session to fetch inFakt backup codes was likewise
for the OTP-app variant this account does not use.
What still does not survive is unchanged and unrelated to 2FA: the
banking pairing ("Usun powiazanie z telefonem") and the local app PIN,
re-paired via SMS like mBank.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JxNrcMDcizNB3XrrYpZF23
|
|||
| 7b1a1656f7 |
Back up what adb can actually reach, and block YouTube in Firefox too
phone_backup.sh captures package lists (including what was purged for user 0), every third-party APK with its splits, /sdcard, all three settings namespaces, and the focus-mode state. Verified: 54/54 packages, 126 APK files, 5.1 GB, splits intact. It is deliberately not sold as a full-device backup, because on an unrooted phone it cannot be one: /data/data is unreachable, so logins, in-app data and device pairings do not survive. It writes a RESTORE-BY-HAND.md naming each of those, with a restore order that avoids a lockout -- mObywatel last, because activating it on a new device deactivates the old one. The first two runs pulled 1 of 54 APKs and then 6 of 54: `while read < file` with adb inside the loop lets adb drain the same stdin. Reading into an array first fixes it, and verify_backup now compares package count against the manifest rather than only checking that the files it did pull are valid zips -- integrity alone called the 1-of-54 run a pass. Also documents the uBlock Origin filters, which block YouTube in Firefox independently of RethinkDNS: force-stopping the VPN still leaves youtube.com blocked by ||youtube.com^. And records what this is worth -- Play offers a one-tap Install for YouTube, the VPN is a few taps from off, and the uBlock page has a Proceed button. Three independent layers, none of them tamper-proof; only Device Owner removes the off switch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JxNrcMDcizNB3XrrYpZF23 |
|||
| 10fff1d1a8 |
Block YouTube on the unrooted Pixel, and trust more than one phone
`pm uninstall --user 0` unregisters a package for one user while leaving the APK on the system image. It needs no root and no factory reset, and unlike `pm suspend` and `pm disable-user` -- both measured to be cleared by a reboot on this device -- it survives one. Applied to the YouTube app, YouTube Music, and Chrome; `--restore` puts any of them back with no download. focus_owner could not have done this: its sweep filters to third-party packages, and YouTube ships at /product/app/YouTube with FLAG_SYSTEM, so the Device Owner path would skip it even after the wipe. Verified on 23181JEGR08034 across two reboots: all three read installed=false, and in Firefox m.youtube.com, www.youtube.com, a /watch URL and a youtu.be short link all fail to connect while example.com loads. The web half of that is RethinkDNS domain rules, which live on the device and are not in this commit; docs/youtube-block-unrooted.md records how to rebuild them, and what this does NOT resist -- the VPN is a few taps from off, which only Device Owner can prevent. The trusted-device record becomes one file per device, so managing a second phone no longer evicts the first. Unenrolled devices still abort, and a changed model or build fingerprint aborts rather than falling through to the next record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JxNrcMDcizNB3XrrYpZF23 |
|||
| b73811c233 |
Add android_ui: drive Android apps by element, never by coordinates
Shared across every repo with an Android surface (screen-locker's workout_app, todo, dufs-cloud/app). Works against a plain release APK on a physical device over adb — no root, no emulator, no Flutter debug connection, no app changes. Driving an app by tapping pixel positions read off a screenshot fails in ways that look like success. All observed in one session (2026-08-10) verifying workout_app's Firebase restore: * A tap aimed at a password field landed with the field unfocused. The typed password went nowhere and NOTHING errored — the next screenshot just showed an empty box. type_into/type_into_field now re-read the field and raise. * Opening the soft keyboard moved a field from y=1572 to y=1319, so a coordinate captured one step earlier was already wrong. Every action re-resolves the element immediately before acting. * A widget behind the keyboard reports its LAID-OUT position (1549) not its real one (1939) — a 390px error that taps a letter key and reports success. keyboard_is_up() distinguishes "absent" from "behind the keyboard", and tap closes the keyboard first. * KEYCODE_BACK is a route pop in Flutter, not a keyboard dismiss: it navigated out of Settings and discarded typed credentials, twice. dismiss_keyboard tries ESCAPE then BACK and verifies via dumpsys input_method. * `input text` inserts at the cursor, so typing over a filled field produced "canary@example.com321krzychu@gmail.com". Fields are cleared first. * uiautomator returns ONLY the focused EditText nodes while a Flutter text field has focus, and stays that way across retries — so a naive retry loop never converges. Partial trees are retried and the best result kept. * An EMPTY EditText has no text, content-desc or resource-id, so a "must be labelled" filter drops the very field you need to fill in. Editable nodes are kept regardless and addressable by position. Ambiguity is an error rather than a silent first-match: `tap "Back"` correctly refuses when "OFFLINE BACKUP" is also on screen. Its own test suite caught a real bug in _escape: backslash sat inside the escape loop, so "&" became "\\&" and a password containing shell metacharacters would have been typed wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JupKvZCNKRmEeRQY3G1unG |
|||
| 590bc2004d |
ci: install jq for the shell tests, and probe for it explicitly
The claude_usage i3blocks tests failed on CI while passing locally. archlinux:base-devel ships no jq, and claude_usage.sh parses its cache with it, so the block degraded to its "no data" path and every percentage assertion failed. That degradation is correct at runtime — the bar should not break because a tool is missing — but it made a missing dependency look like a logic bug. Install jq alongside git and strace, and probe for it at the top of the section so the failure names its own cause. The probe runs jq rather than calling `command -v`: a jq that exists but cannot execute produces exactly the same "no data" output as an absent one, and `command -v` passes in that case — verified by stubbing a jq that exits 127. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YQLwdquDCNmWtvgi3Lk72J |
|||
| 91061edc14 |
feedback: local-only pacman package build, and ignore build artifacts
fee[dB]ack has no AUR entry, so install_feedback.sh builds a local package from the PKGBUILD tracked beside it, the same way hypersomnia-appimage and jackify-bin are installed here. The script only builds and then prints the `sudo pacman -U` line to run, since interactive sudo prompts belong to the user rather than an agent. The built .pkg.tar.zst is ~640 MB, so it is gitignored along with the Bottles prefixes and the archwiki_rag vector index — all three are local runtime state that is regenerable and must never enter the history (see memories/binary-files.md). The PKGBUILD and the installer are the source and stay tracked. Exclude PKGBUILDs from shellcheck. A PKGBUILD is a makepkg manifest, not a standalone script: it has no shebang by design, pkgname/pkgver/ source/sha512sums are read by makepkg, and pkgdir/srcdir are injected by it, so shellcheck flags every one as unused or undefined. The same reasoning already excludes zsh — the dialect is outside what shellcheck can judge — and the alternative was a directive on nearly every line. Also drops three stray files: a saved search-results page, a 0-byte file from a mistyped redirect, and a June i3 config backup superseded by git. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YQLwdquDCNmWtvgi3Lk72J |
|||
| 86565bdef6 |
archwiki_rag: offline semantic search over the Arch Wiki
Indexes the arch-wiki-docs dump that is already on disk and exposes it
to the knowledge-rag MCP server, so Arch questions can be asked by
symptom ("why doesn't my systemd user unit see DISPLAY under i3")
rather than by page title, with no network round-trip at query time.
The one Arch-specific MCP server in the wild fetches pages over the
network and bundles pacman operations that duplicate the local yay-mcp.
This does neither: it only searches, against a local corpus.
install.sh installs its own dependencies, wires the MCP config and
registers a pacman hook so the index follows package updates. The
generated index is machine-specific and regenerable, so it is
gitignored rather than committed.
_reindex_entry runs under the knowledge-rag interpreter, where
mcp_server is importable and this repo's packages are not. Resolving
that import with importlib.import_module states the deferral outright
instead of a function-level import statement that reads as an oversight,
and the backend failure path now re-raises as SystemExit, so the
exit-code translation is explicit rather than a swallowed exception.
Both replace lint suppressions; the package carries none.
pylint runs in its own venv, so beautifulsoup4 and markdownify are
declared there too — being in meta/requirements.txt does not make them
resolvable to the hook.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQLwdquDCNmWtvgi3Lk72J
|
|||
| cef72fbab0 |
syncyomi_guard: refuse a collapsed payload as the new good copy
TachiyomiSY 1.13.2 can report a restore as SUCCESS while writing almost nothing, then sync that near-empty library up and replace the only good copy. On 2026-08-09 a 14 MB payload holding 2182 manga became a 267 KB stub, and the real library survived only as stale pages in the SQLite write-ahead log. The upstream bug is Kotlin in an APK and cannot be fixed here, so this targets the fixable half: decode the payload, count what is actually in it, and refuse to treat a collapse as normal drift. A pure is-it-zero check would have caught this incident but not the harder case upstream describes, where a restore keeps some entries and drops the rest, so the threshold is proportional. Categories get their own rule because all 2182 manga survived the round trip and all 18 categories did not. Adds the setup script and a runbook covering the WAL-page salvage used to recover the library on the day. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YQLwdquDCNmWtvgi3Lk72J |
|||
| f33fd03a16 |
focus_owner: keep the unhide path reachable, never hide the enforcer
setApplicationHidden drops a package from the default PackageManager enumeration even though it stays installed=true, so a sweep querying with flags 0 could never see anything it had already hidden. That made packagesToShow permanently empty: measured on device, a pass following one that hid three apps reported "hid 0, restored 0" and left them hidden with no way back. Query with MATCH_UNINSTALLED_PACKAGES instead. Hiding the enforcer itself is the other unrecoverable case, since it takes the escape hatch and the trigger with it. The exporter now injects com.kuhy.focus_owner into both the day and night allowlists so the decision layer cannot propose it, and the runner keeps its point-of-use skip to cover a policy asset rendered before this fix or hand-edited. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YQLwdquDCNmWtvgi3Lk72J |
|||
| 58c1df573c |
i3blocks: add Claude usage block, fix icon rendering, drop dead blocks
Add a [claude_usage] block showing the 5h and 7d rate-limit windows. It
reads ~/.claude/cache/limit-state/*.json, which Claude Code's statusLine
hook already writes to disk, so the data costs nothing to display: the
script only runs jq against a local file and makes no API call. Limits
are account-wide while the cache is sharded one file per project, so it
takes the newest file and ignores cwd. Percentages arrive as floats and
are truncated; a stale cache is labelled rather than shown as current,
and a rolled-over window reads "?%" instead of a misleading value.
Replace Font Awesome private-use-area icons with plain Unicode. Both
fonts named in the bar's pango string ("System San Francisco Display",
"FontAwesome") are not installed, so PUA codepoints fell through to
whatever font claimed them: ethernet rendered as a star, wifi as
Cyrillic, gpu as Hangul, disk as a chess pawn. Emoji resolve through
fontconfig consistently, which the pre-existing volume block already
relied on, and need no change to ~/.config/i3/config.
Remove the bluetooth, battery, wifi, activitywatch, warp and
network_monitor blocks: this desktop has no BT adapter, no battery, no
wireless interface, and no warp-cli. This also removes a rendering bug,
since activitywatch_status.sh and warp_status.sh were interval=persist
yet emitted three lines per update, leaking their colour line into the
bar as literal "#FF0000" text.
Fix ethernet reporting "down" on a live wired link:
find_ethernet_interface returned the first non-loopback interface, which
with docker installed is a br-* bridge (permanently down, and sorted
before enp*). It now requires a real /sys/class/net/*/device and prefers
an interface that is up.
Tests drop the assertions and fixtures for the removed blocks and add
claude_usage coverage (fresh, warn, critical, stale, rolled-over, null
reset, unparsable, missing dir), a guard that removed blocks cannot
return, a repo-wide check that no block script uses a PUA codepoint, and
an ethernet physical-NIC regression check. The fork budget is asserted
against a populated cache directory, which caught a stat call inside the
scan loop that cost one fork per cached project (170 -> 2 execs).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQLwdquDCNmWtvgi3Lk72J
|
|||
| 5d0caa9725 |
reverse_survivors: difficulty tiers
Haunting, Normal and Crusade scale enemy hp and speed, the survivor's health, your income, and the length of the night. Normal is exactly 1.0 on every multiplier — it is the identity tier, which is what lets every existing raw-value assertion keep passing untouched. Enemy speed now derives from a single helper, so difficulty and frenzy compose in one place rather than fighting at three call sites. Also fixes a bug this surfaced: handleRestart ignored the selected tier, so restarting after choosing Crusade silently reverted to Normal. The react-hooks purity rule flagged Date.now() in the new handler, and extracting restartSeed() exposed the omission. Endless mode is deliberately left out — fmtTime(Infinity) renders Infinity:NaN, which would cost a guard plus tests for a mode whose only content is the same game but longer. README rewritten for all four features, including the reversal of its old placement promise. 160 tests, 100% coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VFyXexNjMQG9V7K8de17fc |
|||
| 2d66bfdd61 |
reverse_survivors: director powers — ambush, frenzy, rift
Ambush drops a unit beside the survivor, inside the ring its kiting AI would otherwise keep clear. Frenzy drives the whole horde faster and harder for six seconds. Rift pins every spawn to one edge for ten, so you can mass a flank instead of scattering. This reverses the README's old promise that placement is never yours to choose. The interesting decision turned out to be what a placement is worth, not being denied one — so placement is purchasable, never free: a premium for Ambush, a cooldown and a commitment window for Rift. A shared spendPower gate covers cooldown and affordability for all three, so the next power costs no new branches. EDGES became a Record keyed by a finite union rather than an array: under noUncheckedIndexedAccess an array index is possibly-undefined and would force an unreachable fallback. The unpinned spawn path still draws the same RNG values in the same order, verified against the old implementation across 50 seeds — existing seeded replays are unchanged. 145 tests, 100% coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VFyXexNjMQG9V7K8de17fc |
|||
| 43b63ac266 |
reverse_survivors: survivor status effects (slow, suppress, bleed)
The warden mires, the artillery stifles, the leech unknits. All three need contact, so a survivor that kites cleanly takes none of them — debuffs punish positional failure rather than taxing existence. isOn/factor are written as arithmetic rather than comparisons: Math.min(1, Math.ceil(t)) is 1 while a timer is live and 0 once it expires, so consumers multiply instead of branching. The whole status module therefore contributes zero branches, which is what makes the 100% gate affordable for a system this cross-cutting. Statuses are applied unconditionally too; a 0-duration spec is a provable no-op. applyStatus uses Math.max, so re-applying refreshes rather than stacks. That caps a horde's total lockdown at one duration by construction — a structural guarantee, not a tuned number. 123 tests, 100% coverage. Branches 117 -> 119: the draw halo gate and the snapshot chip filter. The status logic itself adds none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VFyXexNjMQG9V7K8de17fc |
|||
| f0712d3854 |
reverse_survivors: add splitter, artillery, warden and the leech boss
Splitter dies into three rushers, so killing it creates pressure rather than relieving it. Artillery outranges the survivor (460 vs its 260) and holds station beyond reach, the first unit its kiting AI cannot answer. Warden outruns it. Leech is a third boss. Two new EnemySpec fields carry this: spawnKind generalizes the hivemind's hardcoded 'rusher' minion, and splitCount drives on-death splitting. split() is called unconditionally and is a no-op at splitCount 0 — a guard branch would need two tests to satisfy the 100% gate and buys nothing. Child offsets are fixed rather than drawn from the RNG, so seeded replays are unchanged. 105 tests, 100% coverage. Branch count stays at 117: this adds none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VFyXexNjMQG9V7K8de17fc |
|||
| b0c7baf22e |
Log why Chromium browsers die; whitelist librewolf
Thorium was "closing and dying randomly" with no evidence anywhere.
Two independent structural causes, both now fixed:
1. Crashpad was never enabled -- no crash_reporting consent key and an
empty crash DB, so no minidump was ever written.
2. stderr from a dmenu/.desktop launch reaches no terminal and no
journal. The only Thorium lines ever seen in the journal came from a
systemd-launched instance, which is why they survived.
browser-preexec-wrapper.sh now gives every Chromium-family launch its own
log under $XDG_STATE_HOME/browser-crash-logs: a VRAM/RAM/kernel/driver
snapshot before start, the browser's stderr, and on exit the exit code,
decoded signal, and a SECOND VRAM snapshot. Those two snapshots plus the
signal are the discriminator between the live hypotheses -- a VRAM death
shows NVRM/NV_ERR_NO_MEMORY or near-full VRAM at exit, a stale-binary
death shows SIGSEGV with the GPU idle. Root cause is deliberately NOT
asserted here; the next crash decides it from evidence.
Three details that are load-bearing rather than incidental:
- The exit record is written from a signal trap, not inline after
`wait`. If the wrapper itself is killed, an inline block never runs
and the log ends mid-stream with no verdict -- indistinguishable
from a browser that is still running.
- The browser is a direct redirect, NOT a pipe into a size filter: in
a pipeline `wait` reports the last element's status, discarding the
browser's signal, which is the one fact worth capturing.
- Logs are capped by bytes as well as count. Measured: ~2 MB per 45s
of navigation (65% VERBOSE1 net/TLS chatter), ~0 while idle. Count
alone would not bound disk use, and a full disk causes exactly the
random deaths under investigation.
setup_periodic_system.sh: rewrite Chromium "app mode" .desktop entries
(Exec=/opt/<browser>/...) to go through /usr/local/bin. These are
auto-generated and bypass the wrapper entirely, so neither the flags file
nor the logging applied to them -- five Thorium entries were found still
launching without the --disable-gpu-compositing mitigation added on
2026-07-25, two weeks after the fact. Runs on every install because
Chromium regenerates the files.
pacman_whitelist.txt: allow the exact package name `librewolf`. The
`librewolf` keyword stays blocked, as do all other Firefox forks. It is
LeechBlock-enforced via /etc/librewolf/policies (force_installed), so
this is not an unfiltered escape hatch; it is needed because Chromium's
MV2 sunset ends full uBlock Origin with all filter lists, which the Gecko
build keeps.
Verified: shellcheck clean, no lint suppressions; fake-browser SIGSEGV
and wrapper-SIGTERM both produce a verdict; trim watchdog bounds a
runaway log; kill -SEGV on the real /opt/thorium-browser/thorium wrote
"exit code: 139 / died on signal: 11 (SEGV)"; librewolf installs and
loads with uBO + LeechBlock active.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ME5ZkgWjFvPZrUwMpVZM3e
|
|||
| a96605dc4b |
Wire the enforcement decision to the DPM hide call
The decision layer and setApplicationHidden were both built and tested but never connected -- the service logged a tick. Joining them meant porting the decision to Kotlin, because a background Service has no Flutter engine and starting one per alarm to answer a pure question would be slow and would add a failure mode at precisely the moment enforcement has to be reliable. That duplication is only safe if the two cannot drift, so the Kotlin suite restates the Dart cases one for one rather than testing something adjacent. It was checked for vacuity by asserting the hidden set contained a package that is not installed: 13 tests completed, 1 failed, and it was the right one. Show is applied before hide. If a pass is interrupted -- process killed, device sleeps -- the half that ran is the half that restores access rather than the half that removes it. The launcher, the protected prefixes and the app's own package are re-checked at the point of use as well as in the policy, because hiding the dialer would be unrecoverable from the device itself. An unreadable policy yields no decision rather than an empty allowlist, which would hide every app. The next run is scheduled in a finally, so an enforcement bug costs one tick instead of ending the chain. Note for the next session: JDK 26 is the system default and breaks the Android jlink transform; the Gradle tests need JAVA_HOME=/usr/lib/jvm/java-21-openjdk, which is what Flutter itself uses. NOT verified on device. Unit tests cover the decision, but no real hide/show pass has been observed -- a managed profile was created and the app made profile owner, and the UI confirmed the policy loaded, but the session ended before a pass was triggered. The profile was removed and user 0 verified clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL |
|||
| cc804dd311 |
Run enforcement from exact alarms, and re-arm it before first unlock
The decision layer needed a runtime. A polling loop was the obvious design and the wrong one: Doze can skip a poll indefinitely, and skipping the 05:00 boundary leaves the curfew allowlist applied into the morning -- the user finds their banking app missing with no obvious cause. The transitions are known ahead of time, so they get exact alarms. The service wakes, applies, schedules the next run and stops rather than sitting resident. BootReceiver takes LOCKED_BOOT_COMPLETED as well as BOOT_COMPLETED, because the latter does not arrive until first unlock. That is not theoretical: this phone sat at RUNNING_LOCKED for minutes after an earlier reboot in this session, with no network and no app able to start. Relying on BOOT_COMPLETED alone freezes enforcement state across a reboot -- safe for hiding, not for unhiding, which is the direction that strands you without a dialer. The schedule is re-armed before unlock; the pass itself waits until storage is readable. The first on-device run logged "exact alarms not permitted", and dumpsys showed what that costs: window=+11m14s, maxWhenElapsed=+25m51s, so a 15-minute fallback could slip to 26 minutes. SCHEDULE_EXACT_ALARM is user-revocable and denied by default on Android 16; USE_EXACT_ALARM is the permission for apps whose function is acting at specific times, and is granted at install. After the change: window=0, exactAllowReason=policy_permission, whenElapsed equal to maxWhenElapsed. The service reports "not DO/PO - no enforcement applied" and does nothing when unprovisioned, rather than failing quietly, and a failure inside a pass cannot stop the next run being scheduled -- an enforcement bug should degrade one tick, not end the chain. Not yet wired: the service logs a tick rather than calling setApplicationHidden with the decision. Both halves are tested; joining them is next. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL |
|||
| d0fcda3a47 |
Provision home coordinates out of git; layer the workout signal; drop the test hook
The geofence needs HOME_LAT/HOME_LON, but those say where I live, so the committed policy asset is generated with --redact-home and the real values are pushed to app-private storage instead. push_home_location.sh greps config_secrets.sh rather than sourcing it -- that file holds other secrets and this script has no business evaluating them -- and refuses the shipped REDACTED_* placeholder with a message naming the file to fill in, rather than writing whatever the parse happened to land on. Private storage was chosen for its lifetime as much as its permissions: it does not survive an uninstall, so reinstalling forces re-provisioning instead of silently inheriting a stale home. The default umask left the file 666, so the script chmods it 600; the shell user gets Permission denied, and the /data/local/tmp staging copy is removed after the transfer. Every read failure returns null rather than throwing. Null reaches the enforcement layer as location-unknown, which already fails closed into enforcing -- so a corrupt coordinates file cannot become an unlock, and cannot crash the service either. Workout detection replaces a root-only SQLite read of StrongLifts' private database. The manual source is time-boxed rather than a plain toggle, because a workout mode left on by accident is indistinguishable from having no exception at all. The guarded wrapper catches broadly and times out: nothing about a workout probe failing should be able to stop enforcement running. The combined source can name which signal opened the exception, so the UI can explain an unlock rather than presenting it as magic. Only the two sources verified to exist on this device will be wired -- com.kuhy.workout_app exposes nothing readable and org.runnerup is not installed. HideTestReceiver is deleted. It proved setApplicationHidden persistence, which is what it was for, but it applied policy from an exported receiver and that has no place in a build that will later hold device owner. Confirmed absent from the rebuilt and reinstalled APK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL |
|||
| 1b7958143d |
Prove DPM app-hiding survives a reboot; build the enforcement decision layer
The whole Device Owner design assumed setApplicationHidden persists across a reboot, and that had never been tested -- while both shell primitives were measured to be cleared by one. Settling it looked like it needed a factory reset. It does not: a managed profile is removable and takes its profile-owner grant with it, so the DPM API can be exercised and then erased. pm create-user --profileOf 0 --managed -> user 10 dpm set-profile-owner --user 10 ... ... hide, reboot, re-check ... pm remove-user 10 hidden=true before the reboot, hidden=true after, agreed by dumpsys and by the DPM API itself. Afterwards: Device Owner Type -1, no restrictions, and Chrome in user 0 untouched. Scope limit, stated because it is easy to overclaim: this proves the API's hidden state persists under PROFILE OWNER inside a work profile. It does not prove a DEVICE OWNER can hide a given app in user 0, which is what the design actually needs -- DO there additionally cannot touch the launcher, the default dialer/SMS/IME or packages the system deems critical, and the call can return false silently. That must be tested in the first minute after provisioning, while clearDeviceOwnerApp() is still a cheap exit. On top of that, EnforcementDecision turns policy plus conditions into a hide/show set. It is a pure function, so the whole decision layer is tested without a device, a clock or a location provider. packagesToShow is deliberately the complete allowed set rather than a delta. Failing to hide something is a mild disappointment; failing to UNHIDE strands the user without a dialer or a bank at 09:00 because the phone rebooted during curfew. Applying the full set on every evaluation makes a missed run self-repairing, which matters because BOOT_COMPLETED does not fire until first unlock -- observed directly, the phone sat at RUNNING_LOCKED for minutes after a reboot with no network at all. The workout exception is expressed as packages rather than domains. The rooted system unblocked youtube.com in a hosts file; hiding an app cannot target a domain, so the package set is derived from the configured domains and config.sh stays the source of truth for intent. Signal sources were checked on the device before being built: StrongLifts is granted health.WRITE_EXERCISE and Health Connect is installed, so that path is viable; com.kuhy.workout_app exposes only a MainActivity and Flutter boilerplate; org.runnerup is not installed here at all. Only the two that can actually produce a signal will be wired. HideTestReceiver is temporary and must be deleted before any provisioned build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL |
|||
| 7cfe188bbe |
Fix engine pruning: disabled -> inactive (they were still running)
The previous commit's pruning did not actually take effect. Per enginelib,
"disabled" only sets the DEFAULT for a fresh visitor -- "will allow the user
to manually activate it in the settings" -- so any browser session with an
existing preferences cookie kept running every pruned engine. yandex images
was still throwing JSONDecodeErrors in the log minutes after being
"disabled". "inactive" is the key that actually removes an engine.
Converted 84 entries. Verified behaviourally rather than by reading config:
an images search now serves from google cse images ALONE (20 results,
unresponsive=[]), and no pruned engine appears in the logs after a restart.
brave and qwant deliberately keep "disabled". Sibling engines reference
their outgoing connection pool by name (network: brave <- brave.images/
.videos/.news; network: qwant <- qwant news/images/videos), and "inactive"
removes the definition entirely, so the container dies at startup with
KeyError: 'qwant'. "disabled" keeps the pool defined while hiding the engine.
Pool owners in this image: brave, qwant, yandex, piped, yacy.
Also fixes two status checks that could report a healthy instance as broken.
Public /search was returning 429 while loopback returned 200, which looked
like a regression but was a missing Accept-Language header in the request:
botdetection runs one check per header and rejects on the first failure.
Setting "debug: true" under general: makes SearXNG log the exact rejecting
method ("NOT OK (searx.botdetection.http_accept_language)"), which settled it
in one run after several wrong theories. wait_for_site and check_headers had
the same omission and are fixed; the full required header set is documented
at check_http.
Public path confirmed working end to end: general 200 with 21 results in
0.71s, images 200 with 40 proxied thumbnails in 0.58s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb77nxXGwFYFM4fyXEQkzG
|
|||
| dc45779d38 |
Prune 84 SearXNG engines; images category now sub-1s
kuhy went through the /preferences engine table and selected every erroring engine (CAPTCHA / access denied / timeout / parsing error / crash) plus the slow tail, across all nine categories. Applied all of them, keeping only google cse images for the images category as requested. Result: unresponsive_engines is now [] in EVERY category. Per-category latency (q=linux): general 572ms, images 406ms, videos 1378ms, news 455ms, music 461ms, it 1698ms, science 1338ms, files 584ms, social media 1074ms. Images went 2.1s -> 406-900ms across 12 queries, still 20 results. The root cause was not google cse images being slow (1.1s): SearXNG waits for the SLOWEST engine in a category, so quark images at 2.9s set the floor. Cutting the others removed the ceiling. Three engines that were not in the list surfaced as unresponsive only under load and were pruned with the rest: brave.images, startpage images, kickass. Corrects a wrong claim from the first pass. I had said general search retained three working sources because !go returned 20 results and !bi returned 10. Upstream marks both google and bing "inactive: true", so they answer ONLY to an explicit bang and were never part of the merged view -- which google cse has been serving alone all along. The pruning therefore removed only engines already contributing zero, but general search does have a single point of failure, so status now prints the !go/!bi fallback when a live search returns nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cb77nxXGwFYFM4fyXEQkzG |
|||
| 84d1c6e897 |
Record browser CSP verification for SearXNG
Closes the one gap left open at deploy time. The strict CSP (default-src 'none') had only been validated by static asset analysis, because headless chromium produced empty output on this host and the browser extension was not connected at the time. Loaded the live instance in a real browser: front page, text search, image search and autocomplete all work, and the UNFILTERED console shows 4 messages total -- all benign [PLUGIN] calculator DEBUG lines from SearXNG's own bundle, zero "Refused to load" CSP violations. The "?" tiles in image results are upstream 404s, not blocked requests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cb77nxXGwFYFM4fyXEQkzG |
|||
| bd725b21b7 |
app_icons: add a decision-tree glyph and register untools
The new ~/untools app (guided thinking tools) needs a family launcher icon rather than the stock Flutter beach one. The glyph is one node branching into two -- "decompose a problem into parts", which is the move every one of the app's 25 frameworks makes in some form. Nodes are hollow rings rather than filled dots so the 72px stroke leaves visible interior at icon size. A two-level, seven-node tree was drawn first and rejected on the generator's own evidence: it reported the glyph overflowing the 560px safe box by ~170x162px, and rendering confirmed the doubled connectors closed up into a solid mass at launcher size. One clean split survives the mask and still reads as a hierarchy. Branch spread was then narrowed to clear a residual 44px. linux=False for this app: its desktop build is the web build in a Chrome --app window, so there is no GTK app to hand a hicolor PNG to. Also whitelists com.kuhy.untools in phone_focus_mode/config.sh. That entry is source-of-truth only -- the current Pixel 6a is not rooted, has no /data/local/tmp/focus_mode and no running daemon, so nothing was pushed to the device and no app is being killed there. Kept for the rooted BL9000 phone. Verified: 44 app_icons tests pass with the package at 100% coverage; the generator emits 6 assets with no safe-box warning; the rendered icon was inspected and matches the family; and the resulting APK installed and stayed alive on 23181JEGR08034. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019hVZ5HfcrcrVzinmjQuQup |
|||
| 8f2c46de7c |
Add setup_searxng.sh: self-hosted SearXNG at searx.kuhy.duckdns.org
Deploys a SearXNG metasearch instance behind the existing shared Caddy edge. Graded A+ (score 120, 10/10) by the MDN Observatory API, and "Vanilla" in the sense that every change is settings-only -- no template or static-file edits. Public but deliberately not submitted to the searx.space directory. Both containers run network_mode: host because Docker bridge networking has zero egress on this box (nftables' default-drop forward chain is terminal at NF_INET_FORWARD regardless of Docker's legacy-iptables ACCEPTs), so a stock bridge compose cannot reach a single search engine. Non-obvious things established empirically against the pinned image, all of which cost a debugging cycle and are commented at their use sites: - granian binds :: (all interfaces) by default, NOT the 127.0.0.1 its own docs claim; with host networking that would have LAN-exposed the instance. GRANIAN_HOST/PORT are the only things pinning it. settings.yml's server.bind_address is inert (read only under __main__). - Caddy's `> Header "v"` is a substring-replace, not a header setter -- it silently set nothing. The bare name is "set"; `+` appends and would duplicate the headers SearXNG already sends (X-Content-Type-Options, Referrer-Policy). - server.public_instance force-enables link_token, a browser-proof challenge no scripted client can pass, which makes the JSON API unusable. Dropped it and kept limiter/image_proxy via their own keys. - Without limiter.toml there are no trusted_proxies, so botdetection 429s every request including the homepage. - The entrypoint chowns config to uid 977 and needs DAC_OVERRIDE to read it, so cap_drop breaks startup and mode 600 breaks the worker's read (640 is right). - `compose up -d` is a no-op when only a bind-mounted config file changed, so an explicit restart is required or every check tests the previous config. - All curl checks must send browser-shaped headers; bare curl gets 429, which had the status command reporting a healthy instance as broken. Engine pruning is measured, not guessed: brave/duckduckgo/startpage CAPTCHA on 100% of queries, flickr is the 1.0s tail, and pinterest was failing because this host's own /etc/hosts blackholes it -- a local cause that would otherwise have been misdiagnosed as a dead upstream. Measured latency, reported honestly: front page median 19ms, but search median 486ms (233-623ms). Sub-200ms search is not achievable by any configuration -- a metasearch cannot return faster than the slowest upstream it queries. The ~200ms figure on searx.space measures initial front-page response, which is met. Image pinned by digest: the workarounds above are only known-true for 2026.8.4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cb77nxXGwFYFM4fyXEQkzG |
|||
| 82e936e62b |
app_icons: add the kuhylog app and its track-bars glyph
Three rising bars with a plotted point above the tallest: a day-by-day log that adds up to a trend, which is what the app tracks. Bars are strokes rather than filled rects so the glyph keeps one stroke weight, and the 168px pitch leaves 96px gaps -- comfortably above MIN_NEGATIVE_SPACE, which is where these glyphs usually fail. kuhylog has a Linux desktop target as well as Android, so it is registered with linux=True and gets the hicolor set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HQtqicQHSrvBa4XaNNh9Wy |
|||
| 137fc99a2e |
app_icons: add the anvil glyph and register octoforge
OctoForge (~/octoforge) is app #10 in the shared launcher-icon family. It was still shipping the stock Flutter icon, which is not a finished state. The glyph is a filled silhouette rather than a stroked outline: a stroked anvil is a long thin horn plus a narrow waist, and at the family's 72px stroke weight the waist closes up solid at 48dp. An octopus (GitHub trade dress, and a tangle of thin curves that blurs at launcher size) and a bare hammer (reads as a generic tool, not a forge) were both rejected. The horn is a separate wedge projecting off the face, not a rounded corner on it. Two earlier drafts folded the taper into the slab's left end and both rendered as a bump, leaving the shape reading as an I-beam. It now tapers to a 40px blunt tip, above MIN_NEGATIVE_SPACE so it survives at 48dp. Registered with linux=True: unlike the other phone-first apps this one has a real desktop target with a .desktop entry, so it needs the hicolor set. Verified: generated for Android and Linux, checked against the family preview sheet at all three sizes, and confirmed in the device app drawer next to todo. Committed from a temporary worktree: the working tree held another session's in-flight kuhylog entry in the same hunk, and git-rules.md forbids stashing around other agents' uncommitted work. That work is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzEA5srk8VPn6mKKjCaGpb |
|||
| 143d260c1c |
Park phone DNS filtering: Private DNS fails closed, so the PC cannot host it
Measured on the Pixel 6a, twice: with private_dns_mode=hostname and the resolver unreachable, `ping example.com` returns "unknown host". Android's Private DNS does not fall back to the carrier's resolver -- hostname mode is RFC 8310's strict profile, and failing to establish TLS on 853 is a hard error. So a resolver living on this PC means "PC off" equals "phone has no internet", not merely "no filtering". That is disqualifying for a daily driver, and it disqualifies the whole home-hosted delivery path regardless of how well it works while the machine is up -- which it does; it was verified end to end. DNS_TRUSTED_DOT_HOST/IPS go back to empty, which restores the previous enforcer behaviour byte for byte. No code revert is needed, which is exactly why the flag was built to default empty. Two things found while testing are worth keeping. The nftables input chain is policy drop and admitted only ssh over wg0, so binding stunnel to the tunnel address was not sufficient on its own: ICMP passed while TCP 853 was dropped, which presents confusingly as "ping works but the port does not". That rule is now added explicitly and scoped to wg0. And setup_phone_wireguard.sh automates the client setup the tunnel needed, verifying handshake, reachability and DNS rather than assuming success, and deleting the pushed private key afterwards. The deeper reason the home endpoint could not work even with the tunnel up: dns.kuhy.duckdns.org resolves to the public IP while stunnel listens only on 10.8.0.1, and with AllowedIPs=10.8.0.0/24 that routes outside the tunnel to a closed port. Android never opened a connection at all. Split-horizon DNS does not fix it either, since on mobile data the phone uses the carrier's resolver. Research also ruled out the hosted-hybrid option that looked most attractive: no provider can ingest a 185k-domain list (NextDNS none, AdGuard 1-5K, ControlD 10K), and the free tiers fail OPEN -- they stop filtering past 300k queries a month while still resolving, which for a self-control tool is the worst failure mode available. Details and the remaining options are in the parked doc. The stunnel endpoint is left running deliberately: WireGuard-only, verified unreachable from LAN and WAN, and it makes the next attempt testable immediately. Removal commands are documented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL |
|||
| da6c5427ff |
Format the wipe checklist with prettier
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL |
|||
| c299f95a9b |
Exempt the generated policy asset from prettier
focus_owner/assets/policy.json is rendered by json.dumps, which cannot reproduce prettier's single-line-short-array style, so every regeneration would reintroduce a formatting diff. Format pubspec.yaml, which is hand-edited and has no such conflict. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL |
|||
| e3d5e1dd18 |
Serve the blocklist over DoT and hand the policy to the app as an asset
Two gaps stopped the unrooted path working end to end. The phone had no way to read the focus policy, and the blocklist was only reachable as a hosts file or over plain UDP/53 on the LAN -- while the rooted enforcer actively rejected every DoT connection, so a self-hosted resolver could not have worked even if one existed. focus_owner now loads a generated policy.json instead of hardcoding anything, so config.sh stays the single source of truth across Python, shell and Kotlin. The committed asset is rendered with --redact-home and a test fails if home coordinates ever appear in it. An unknown schema_version is rejected outright: silently accepting a newer schema means enforcing a policy the app has misread, and a misread allowlist is one that blocks the dialer. dns_enforcer.sh rejected ALL port 853 traffic and pinned Private DNS off, every 20 seconds. That is right when DoT is a bypass channel and exactly wrong when DoT is the enforcement channel, which is what an unrooted phone needs -- it has no hosts file to fall back to. With DNS_TRUSTED_DOT_HOST set it now ACCEPTs the trusted resolver before the blanket REJECT, and pins Private DNS to that hostname so the user cannot pick an unfiltered one. Rule order is load-bearing: iptables takes the first match, so an ACCEPT appended after the REJECT would never be reached. expected_rule_count() counts the new rules, because a mismatch there makes chain_intact() rebuild the chain every 20s -- a silent fork storm that also drops the connection each time. With the variable empty the behaviour is byte-identical, so the rooted phone is untouched until this is deployed. setup_dot_resolver.sh terminates DoT with stunnel rather than Caddy: DoT is a raw TLS stream and stock caddy:2.8 has no layer4 module, so using Caddy would have meant an xcaddy rebuild of a container serving five live sites. It reuses the certificate Caddy already renews, so nothing competes for port 80. It binds to the WireGuard address, not 0.0.0.0 -- a public DoT endpoint is an open resolver, and over the existing tunnel it needs no port-forward, no firewall rule and no rate limiting. Verified: example.com resolves, pornhub.com returns 0.0.0.0, TLS 1.3 against the LE cert, and both the LAN and public addresses refuse. The Device Owner checklist is planning only. Checking the device rather than the config settled the one open risk: Google Wallet is not installed and no NFC payment default is set, so the unconfirmable tap-to-pay question does not apply here. The same check found that Aegis, MS Authenticator, Oracle IDM and PKO IKO are absent -- they are in config.sh's whitelist, which describes the rooted phone, not this one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL |
|||
| f636d2b21b |
Make focus policy and blocklist usable without root; fix workout CDN unblock
phone_focus_mode is built for a rooted phone: the hosts layer is a read-only
bind mount over /system/etc/hosts, chosen so that even root-in-a-terminal
cannot overwrite it. None of that ports to a stock locked device. This splits
out the parts that are device-independent so one policy can drive both.
python_pkg/focus_policy parses config.sh rather than copying it, so the shell
config stays the source of truth and the two cannot drift. It rejects the
shipped REDACTED_* coordinate placeholder instead of defaulting to (0,0),
which would move "home" to the Atlantic and quietly disable the location gate.
blocklist.py converts the hosts file to domains for a DoT resolver or a local
VpnService. Three properties of the real 194k-line file make the obvious parser
wrong, and all three are handled: generate_hosts_file.sh *comments out*
deliberate unblocks rather than deleting them, so stripping '#' re-blocks
facebook/messenger/linkedin/4chan; the StevenBlack header carries loopback and
IPv6 housekeeping that would blackhole localhost; and ~1060 upstream annotation
tokens ('#[adware.zeno]', '"spam') render as address=/"spam/, which makes
dnsmasq refuse to start -- taking DNS down rather than blocking anything.
Verified with dnsmasq --test over all 185681 generated domains.
strip_workout_hosts.py had the same class of bug on the live rooted phone:
exact set membership meant the allowlisted googlevideo.com never matched the
dynamic r*---sn-*.googlevideo.com hosts YouTube actually streams from, so the
workout unblock was only partly working. Now suffix-matched (17 lines stripped,
was 14). The three new tests were checked against the original code in a
throwaway worktree; two of them fail there, so they pin the fix rather than
passing vacuously.
focus_owner is a device-owner app that deliberately does nothing yet. It ships
clearDeviceOwnerApp() implemented and tested first, because dpm
remove-active-admin does not work on a device owner: an app that locks the
device down before proving it can let go leaves a soft-brick recoverable only
through fastboot on a locked bootloader. Built, installed and launched on the
Pixel 6a; dumpsys afterwards confirms Device Owner Type -1 and no user
restrictions, i.e. the phone is unchanged.
Measured while scoping this, and not yet reflected in any enforcement code:
on Android 16 both pm suspend and pm disable-user are cleared by a reboot, so
shell-level app blocking is per-session only. setApplicationHidden is assumed
to persist under Device Owner but is untested.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
|
|||
| f1590cbb64 |
Pin prettier, shfmt and zsh too, not just shellcheck
|
|||
| 23fdfcf867 |
Pin shellcheck in CI so local and CI agree on what a finding is
Root cause behind the outage |
|||
| 9dd9aeb50e |
Fix the 11 shellcheck findings that kept CI red since b83ce5d
"Pre-commit checks" has failed on every push since
|
|||
| 1b43ba093c |
mtk_root: build the rooting toolkit before the phone lands
Prep for a Ulefone Armor X12 Pro (MediaTek MT6765) that has not arrived, validated read-only against the Pixel 6a that has. Partition discovery never hardcodes boot vs init_boot. It reads /dev/block/by-name/, derives the slot scheme from observed _a suffixes, and picks the ramdisk carrier by preference order init_boot then boot over what is actually present. The AOSP launch-version rule explains why that ordering is right; the code consults the filesystem, not the API level. One path therefore serves both a Pixel (no init_boot, falls to boot_a) and a 13-launch MediaTek device (init_boot_a taken). Patching the wrong one yields a phone that will not boot. Classification refuses rather than guesses: ULEFONE needs a positive MediaTek signal AND a vendor match, with no "not a Pixel therefore Ulefone" fallback. Carrier lock is always reported UNDETERMINED, because sys.oem_unlock_allowed, ro.oem_unlock_supported and settings global oem_unlock_allowed read identically whether a device is carrier-locked or merely has the toggle off; asserting a negative would wrongly rule out a path. No flashing script, deliberately. Automating irreversible partition writes defeats the confirmation steps that catch a wrong partition or missing slot suffix. Three bugs found by running the code rather than only linting it: - SIGPIPE: `adb version | head -1` under pipefail exited 141 and killed 00-preflight.sh mid-run - 20-dump-stock.sh exited silently when no recon had run, because find on a nonexistent cache dir fails under set -e - the read-only static scan had a hole: stripping all quoted strings to avoid false positives on printf text also hid `adb shell "reboot bootloader"`. Now only printf/echo/cat arguments are stripped, and a negative self-test asserts the scan fails on injected commands Also narrowed a world-writable udev catch-all (idVendor=="*", MODE="0666") that granted every local process read/write access to every USB device; commented with a backup, adb connectivity re-verified afterwards. Validated: 58/58 offline fixture tests; 00-preflight.sh HOST READY (12/12); 10-recon.sh read-only against the Pixel reports PIXEL, boot_a, A/B, no init_boot, path C-or-D indeterminate, with the device unchanged afterwards. Untested and labelled as such in READY.md: the init_boot branch on real hardware, mtkclient over BROM, and the whole flash/verify sequence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ReVsw7qovYkkGbqcU9tn7 |
|||
| b83ce5d3c7 |
Fail the build on any shellcheck finding; fix the 222 the old gate hid
Some checks failed
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
billsplit CI / quality (push) Has been cancelled
reverse-survivors CI / check (push) Has been cancelled
The shellcheck hook ran with --severity=warning, so info- and style-level
findings never failed a commit. 222 had accumulated across 94 of 244 shell
files. They were not all style noise -- six were bugs:
- Five scripts in single_use/misc/testsAndMisc-bash/ sourced
$SCRIPT_DIR/../../lib/common.sh, one directory too shallow. Nothing exists
at that path, so all five failed at runtime.
- setup_personal_website.sh and setup_kcd2_dice_solver.sh called usage()
(which ends in `exit 0`) and then `exit 1` for an unknown command. The
exit 1 was unreachable, so a typo'd subcommand reported SUCCESS.
- Six i3blocks scripts had a `# shellcheck source=` directive missing the
scripts/periodic_background/ path segment. The stale path had silently
disabled source-following for all of them.
- diagnose_arch_performance.sh word-split a newline-joined file list; when
the find came back empty, awk got no file operands, fell through to
reading stdin, and the diagnostic hung.
- lib/common.sh's handle_arg_help_or_unknown ended in an unreachable
`return 0` while documenting "Returns: 0 if handled" -- a contract no code
path could satisfy.
- deploy.sh's SCRIPT_DIR is reassigned by config.sh, which it sources at top
level, and config.sh can repoint it at the on-device path
/data/local/tmp/focus_mode. Renamed to DEPLOY_DIR.
The SC1091 bulk (81 findings) was resolved rather than silenced: .shellcheckrc
sets external-sources + source-path=SCRIPTDIR so sourced files are actually
followed and analysed. That is what surfaced the i3blocks and deploy.sh bugs.
17 `# shellcheck disable=` directives remain, each with a written reason, and
only where the finding is provably wrong: injection-test payloads that must not
expand, literal source text being grepped for, POSIX sh scripts on the phone
where arrays do not exist, and source paths that only exist at runtime.
Everything else was fixed in code.
Verified in a CLEAN worktree, not just my own tree. config.sh sources the
gitignored config_secrets.sh, which exists locally but not in a fresh checkout;
a working-tree-only check passed while ci_mirror.sh -- which lints HEAD in a
clean worktree, exactly as CI does -- failed on it.
The gate is verified to bite: a file whose only finding is SC2016 (info) fails
the hook, while the old --severity=warning gate exits 0 on it.
third_party/ is excluded -- vendored upstream, matching the prettier hook.
Not fixed, reported: check_polling_antipatterns.sh carries a pre-existing
SC2034 disable that hides a real bug -- its `patterns` array is never used, so
the checker detects nothing. Confirmed unchanged by this commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|||
| 79829ec8a2 |
Diagnose the pacman first-hook stall; fix the drift that hid a stale wrapper
pacman intermittently freezes at its FIRST PreTransaction hook - 5 abandoned
transactions since 2026-07-29, stalls of 4 minutes to overnight, always fine on
retry. This investigates it and repairs the real defects found on the way.
The stall is NOT fixed, and deliberately so: no verified defect was found, so
nothing pacman-side was changed.
- Not guard-lib. /etc/guard-lib/targets was empty, so that hook script
provably exits in milliseconds. The failures share POSITION (first hook of
the transaction), not identity: one stall hit the hosts hook back when that
one ran first.
- Not reproducible on demand. 65 real transactions (40 idle, 25 under 2.6 GB
of memory pressure) produced zero stalls. Against the historical 4-in-56
rate that is ~0.9% likely, so the trigger is conditional on something a
synthetic load does not recreate. Memory pressure is untested at the load
that actually occurred - not refuted.
So diagnose_pacman_hook_stall.sh drives real transactions with a watchdog, and
its --watch mode passively captures /proc/<pid>/{stack,wchan,syscall}, the
process tree, meminfo and PSI from the next REAL stall. No pacman-side fix
should ship before that dump exists.
Three real defects were found and fixed:
1. The deployed pacman wrapper was 19916 B against a 27510 B source, never
sourcing the shared pacman_lock_lib.sh that makepkg_wrapper already used.
It hid because check_pacman_wrapper() only tested that files EXIST. Both
installers now write a drift manifest covering source AND installed hashes;
the checker replays it with sha256sum -c and fails closed when it cannot
verify. Manifest paths are absolute - relative ones resolve to nothing under
systemd's cwd=/ and would have looped reinstalling forever.
2. The guard-lib migration (
|
|||
| 4d9f486176 |
billsplit: build a real APK by dropping file_picker for file_selector
billsplit had never produced an Android APK. file_picker 11.0.3 — the latest release on pub.dev — is wedged under AGP 9 in both directions: it skips applying the Kotlin Gradle Plugin when AGP >= 9, so with built-in Kotlin off its own Kotlin never compiles (`cannot find symbol: FilePickerPlugin`), and with built-in Kotlin on its buildscript leaks KGP onto the global classpath via rootProject.allprojects, which AGP then rejects for every module. The app's Gradle config was never at fault: android/app/build.gradle.kts and settings.gradle.kts are byte-identical to habit_stack, home_inventory and diet_guard_app, which all build. Only the dependency differed, so the fix is the dependency — the toolchain stays on the house-standard AGP 9. Ported both call sites to the official file_selector. Its save flow returns a destination and leaves the write to the caller, where file_picker wrote the bytes inside the plugin; that write now goes through an injectable seam because a real dart:io future never completes inside testWidgets' fake-async zone, so a widget test could otherwise never observe the resulting snackbar. The test double moves from a MethodChannel mock to a FileSelectorPlatform fake (file_selector speaks Pigeon, so a channel mock would not intercept it), and a plain non-widget test covers the real on-disk writer to hold 100% coverage without an ignore. The launcher icon is now verified from the built artifact: the ic_launcher assets unzipped from the APK render as the charcoal + gold perforated receipt, not the stock Flutter beach. Peak build footprint is 1.86 GiB, inside budget. Also rewrites ci_mirror.sh to run its gates in a disposable worktree at HEAD. `pre-commit run --all-files` stashes the live tree, and in a shared checkout that made an unrelated agent's uncommitted work collide with hook auto-fixes, failing the push while blaming an innocent hook — and putting that work inside a stash window, which memories/git-rules.md forbids. A clean worktree needs no stash and is a truer mirror of CI, which never sees a dirty file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| 287512f011 |
phone_focus_mode: reach the phone over adb without touching it
Android's Wireless debugging is deliberately ephemeral: the listener dies when you leave the settings screen or the device sleeps, and it picks a new random port each time. In one session it offered 35545, then 40401, then 43781, and vanished entirely twice. Every deploy therefore started with tapping through Settings and reading a port off the screen. enable_persistent_adb.sh waits for any such transient window, then uses it to pin adbd to port 5555 - the port deploy.sh has always assumed - and installs a Magisk service.d boot script so the same happens on every boot. The phone is on DHCP with no reservation, so the script identifies it by MAC rather than trusting a hardcoded address: a ping sweep populates the neighbour table and the address is looked up there. That beats a router reservation because it needs no router access and works on any network. One bug worth naming: the first version took the first `device` line from `adb devices`. Pairing also leaves an mDNS _adb-tls-connect._tcp entry for the same phone, so it reported port 5555 as reachable while nmap showed it closed. It now matches the exact ip:port. Verified: nmap showed 5555 closed then open across the switch; a run against a deliberately wrong IP reported "phone moved" and connected; and after `adb reboot` the script connected with no arguments and no interaction, with /proc/uptime at 149s and service.adb.tcp.port reading 5555, so the boot script really ran. shellcheck clean. Trade-off accepted by the user: adbd on a fixed port is reachable to anything on the LAN, though it still requires an authorised key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |