Skip to content

feat(cli)!: require an explicit app id for android/ios-device delivery - #64

Open
V3RON wants to merge 7 commits into
mainfrom
feat/android-app-id-delivery
Open

V3RON wants to merge 7 commits into
mainfrom
feat/android-app-id-delivery

Conversation

@V3RON

@V3RON V3RON commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Closes #63. Design agreed in this comment.

The problem

deliverAndroid emitted am start -a android.intent.action.VIEW -d '<link>' with no package. When more than one installed app declares the scheme — product flavors with applicationIdSuffix, a debug and a release build side by side, two projects sharing a generic scheme — Android shows the "Open with" chooser. am start still exits 0, so delivery reports delivered: true and appduct_wait_for_session blocks its whole timeout with nothing explaining why. Same failure signature as the ios-device loopback 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's link()) → nearest .appduct/config.json declaring appId.<platform>, via the existing walk-up → usage error raised before minting, so a missing id never strands a pending session. androidappId.android, ios-deviceappId.ios; ios-sim needs none (simctl openurl has 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_connect path — the one the issue is about — exactly as broken. A loud, actionable error beats a silent 120s timeout; that is the trade loopbackAddressMessage already makes. Note the consequence: on the auto-detected Android path an unconfigured project now gets invalid_request rather 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 applicationId out of build.gradle carries the failure mode the issue opens with: a project using applicationIdSuffix resolves the base id, and -p then 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, so com.example.MyApp / com.example.myapp divergence 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. init takes 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 devicectl trailing positional (where a leading - reads as an option) and a string adb shell re-parses on the device's own shell (where a space or ; is injection — the same reason §8 single-quotes the URL).

am start output is checked. am does not reliably exit non-zero, so output matching /^Error:/m becomes a thrown usage error. Without it an unresolvable -p reproduces the very silent hang being fixed.

Breaking

  1. --open android, and Android delivery via appduct_connect, now require an app id.
  2. --bundle-id (CLI), bundleId (MCP / mintLink / appduct/client), and config.json's iosBundleId are removed — no deprecation shim. A leftover iosBundleId is silently ignored (loadConfig's warn defaults to a no-op), so the delivery-time error is the only signal, which is why it names the migration.
  3. Migration, once per app root: appduct init --scheme myapp --android-app-id com.example.myapp --ios-app-id com.example.MyApp.

Out of scope

Static discovery, any state-dir appId key, --ios-app-id/--android-app-id on appduct mcp (the per-call appId is the escape hatch when the server's cwd isn't the app root), doctor changes, and the debug BroadcastReceiver the issue defers.

Testing

pnpm typecheck, pnpm lint and pnpm check:links are clean. Test suite: 711 passed, 1 skipped, 1 failedsrc/__tests__/e2e/daemon-restart.e2e.test.ts ("SIGKILL mid-session…"), which I verified fails identically on the base commit 92e5396 and touches nothing this change modifies. Flagging it rather than papering over it.

New coverage, all through the existing ExecFn seam (no real adb/xcrun): the -p argv with and without -s, a malformed id rejected before any exec call, am start Error: detection, the missing-id failure landing before link.create on both targets, --app-id beating the config and appId.<platform> selected per target, project-config parse/unknown-key errors, init's new flags with merge and --force semantics, and the MCP paths (explicit, auto-detected, and the ios-sim/none rejections).

The second commit is a fix from my own review of the first: resolveAppId was called from appduct_connect without stateDirRoot, while the same server passes it when resolving scheme — without it a --state-dir on the walk-up path would be read at the project tier, inverting the precedence globalConfigDirs exists to enforce.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MixZ87V14ZHMAnT5CCKRiU


Generated by Claude Code

claude and others added 6 commits September 21, 2026 08:08
`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
V3RON force-pushed the feat/android-app-id-delivery branch from 9a58ff5 to 697ebd4 Compare September 21, 2026 06:19
…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
V3RON force-pushed the feat/android-app-id-delivery branch from b39bd23 to 149ad23 Compare September 21, 2026 07:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android delivery: target the app package explicitly so am start can never show a chooser

2 participants