Conversation
`appduct link --open android` and the MCP `appduct_connect` tool delivered the deep link with an implicit intent (`adb shell am start ... -d '<link>'`, no `-p`). When more than one installed app declared the scheme, Android showed an "Open with" chooser; `am start` still exits 0, so delivery reported success and `appduct_wait_for_session` blocked its whole timeout with nothing explaining why (issue #63). Delivery to `android` and the experimental `ios-device` target now names the app explicitly and requires an id rather than falling back: - Android's `am start` argv gains `-p <app-id>`, and `deliverAndroid` treats an `Error:` line in `am start`'s own output as a failure — `am` does not reliably exit non-zero for an unresolvable intent. - A project `.appduct/config.json` gains a platform-keyed `appId` block (`{ "appId": { "ios": "...", "android": "..." } }`), read via a new `resolveAppId`/`readProjectConfigAppId` in `scheme.ts` that mirrors `resolveScheme`'s shape (flag, then project-config walk-up; no env var or discovery tier). `appduct init` gains `--ios-app-id`/`--android-app-id` to write it, with the same merge/`--force` semantics as `--scheme`. - A missing app id is a usage error raised before `link.create`, so no pending session is stranded behind a doomed delivery. - `ios-sim` needs no app id (`simctl openurl` has no equivalent flag); passing one there is itself a usage error. Breaking change and migration: - `--bundle-id` (CLI), `bundleId` (MCP `appduct_connect`), `bundleId` (`mintLink`/`appduct/client`'s `link()`), and `config.json`'s `iosBundleId` are all removed, with no deprecation shim. Replace them with `--app-id` (CLI), `appId` (MCP/programmatic), and a project `.appduct/config.json`'s `appId.<platform>` — the last written by `appduct init --scheme <s> --android-app-id <id> --ios-app-id <id>`. A leftover `iosBundleId` in `config.json` is now silently ignored as an unknown key (`loadConfig`'s `warn` defaults to a no-op), so the delivery-time usage error is the only signal it needs replacing. Docs updated: packages/appduct/README.md, docs/ARCHITECTURE.md, docs/PROTOCOL.md, skills/appduct/SKILL.md, CHANGELOG.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MixZ87V14ZHMAnT5CCKRiU
`resolveAppId` was called from `appduct_connect` without `stateDirRoot`, while the same server passes it when resolving `scheme` at startup. Without it, an operator who points `--state-dir` at a directory on the walk-up path would have that *global* config read at the project tier, inverting the documented precedence that `globalConfigDirs` exists to enforce. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MixZ87V14ZHMAnT5CCKRiU
…unches Android package names allow '_' (com.my_company.app), so the shared shape check rejected valid ids. '_' is inert in both the devicectl argv and the device shell. Also treat 'Exception occurred while executing' and 'Security exception:' output from am start as a failed delivery, since an adb without the shell protocol drops the remote exit status.
init wrote any value into the committed project config, so an id that delivery would always refuse (e.g. 'com.x;reboot') was accepted and only failed on every later link --open. Apply the same shape check at the flag.
--open android now requires an app id. The playground's launcher points --state-dir at playground/.appduct, which the project-config walk-up skips, so its commands must pass --app-id explicitly. Also name appId in the setup guide's init step and in the 'unable to resolve Intent' troubleshooting entry.
V3RON
force-pushed
the
feat/android-app-id-delivery
branch
from
September 21, 2026 06:19
9a58ff5 to
697ebd4
Compare
…t/config.json The playground pinned a committed fixture key (cliPins, trust: "pin"), so it needed its own state dir in playground/.appduct. That made the directory both the daemon's state and the project config, and app ids are only read from a project config, so link --open android needed --app-id every time. Drop cliPins and trust so the playground takes the default link-trust path with the shared ~/.appduct daemon, delete the fixture key, and record the scheme and both app ids in playground/.appduct/config.json. The launcher now only runs the repo's CLI build from the app root. playground-native gets the same config, so its READMEs drop --scheme and --app-id.
V3RON
force-pushed
the
feat/android-app-id-delivery
branch
from
September 21, 2026 07:54
b39bd23 to
149ad23
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #63. Design agreed in this comment.
The problem
deliverAndroidemittedam start -a android.intent.action.VIEW -d '<link>'with no package. When more than one installed app declares the scheme — product flavors withapplicationIdSuffix, a debug and a release build side by side, two projects sharing a generic scheme — Android shows the "Open with" chooser.am startstill exits 0, so delivery reportsdelivered: trueandappduct_wait_for_sessionblocks its whole timeout with nothing explaining why. Same failure signature as theios-deviceloopback case §8 already guards against.The change
Delivery to a target that resolves an app by identity now requires the app's id, and names it explicitly:
am start … -d '<link>' -p <app-id>.One per-invocation flag, one platform-keyed config block:
{ "scheme": "myapp", "appId": { "ios": "com.example.MyApp", "android": "com.example.myapp" } }Resolution per target:
--app-id/appId(MCP,mintLink,appduct/client'slink()) → nearest.appduct/config.jsondeclaringappId.<platform>, via the existing walk-up → usage error raised before minting, so a missing id never strands a pending session.android→appId.android,ios-device→appId.ios;ios-simneeds none (simctl openurlhas no equivalent flag) and passing one there is a usage error.Decisions worth a reviewer's attention
Required, not optional-with-fallback. Falling back to today's implicit intent would leave the zero-argument
appduct_connectpath — the one the issue is about — exactly as broken. A loud, actionable error beats a silent 120s timeout; that is the tradeloopbackAddressMessagealready makes. Note the consequence: on the auto-detected Android path an unconfigured project now getsinvalid_requestrather than the QR fallback it would previously have degraded to. That is deliberate — the error names the exact command to run — but it is the one behavioural change that goes beyond "add a flag".No static discovery of the id. Reading
applicationIdout ofbuild.gradlecarries the failure mode the issue opens with: a project usingapplicationIdSuffixresolves the base id, and-pthen targets an app that either isn't installed (loud) or is the wrong flavor declaring the same scheme (silent, and worse than the chooser). Guarding it needs flavor heuristics and "sources disagree" rules — more machinery than a committed config line.Config is per-platform, the flag is not. Android forbids
-and is conventionally lowercase, socom.example.MyApp/com.example.myappdivergence is real and a single config value would feed the wrong id to one platform. A flag doesn't have that problem because the target is known when it is typed.inittakes two independent flags for the same reason and never guesses one from the other.One shape check for both platforms. The existing bundle-id pattern gains a second caller. It is a safety check, not a grammar check: the value reaches both a
devicectltrailing positional (where a leading-reads as an option) and a stringadb shellre-parses on the device's own shell (where a space or;is injection — the same reason §8 single-quotes the URL).am startoutput is checked.amdoes not reliably exit non-zero, so output matching/^Error:/mbecomes a thrown usage error. Without it an unresolvable-preproduces the very silent hang being fixed.Breaking
--open android, and Android delivery viaappduct_connect, now require an app id.--bundle-id(CLI),bundleId(MCP /mintLink/appduct/client), andconfig.json'siosBundleIdare removed — no deprecation shim. A leftoveriosBundleIdis silently ignored (loadConfig'swarndefaults to a no-op), so the delivery-time error is the only signal, which is why it names the migration.appduct init --scheme myapp --android-app-id com.example.myapp --ios-app-id com.example.MyApp.Out of scope
Static discovery, any state-dir
appIdkey,--ios-app-id/--android-app-idonappduct mcp(the per-callappIdis the escape hatch when the server's cwd isn't the app root),doctorchanges, and the debugBroadcastReceiverthe issue defers.Testing
pnpm typecheck,pnpm lintandpnpm check:linksare clean. Test suite: 711 passed, 1 skipped, 1 failed —src/__tests__/e2e/daemon-restart.e2e.test.ts("SIGKILL mid-session…"), which I verified fails identically on the base commit92e5396and touches nothing this change modifies. Flagging it rather than papering over it.New coverage, all through the existing
ExecFnseam (no realadb/xcrun): the-pargv with and without-s, a malformed id rejected before any exec call,am startError:detection, the missing-id failure landing beforelink.createon both targets,--app-idbeating the config andappId.<platform>selected per target, project-config parse/unknown-key errors,init's new flags with merge and--forcesemantics, and the MCP paths (explicit, auto-detected, and theios-sim/nonerejections).The second commit is a fix from my own review of the first:
resolveAppIdwas called fromappduct_connectwithoutstateDirRoot, while the same server passes it when resolvingscheme— without it a--state-diron the walk-up path would be read at the project tier, inverting the precedenceglobalConfigDirsexists to enforce.🤖 Generated with Claude Code
https://claude.ai/code/session_01MixZ87V14ZHMAnT5CCKRiU
Generated by Claude Code