Give the phone both pickers, and settle who signs the APK

The files screen could browse a remote and delete on it, and that was all: there
is no browsable local filesystem on Android for a second pane to show, so the
gesture the desktop is built around — choose on the left, press the arrow — has
nothing to stand on. What replaces it is the platform's own two pickers. ADD
FILES is ACTION_OPEN_DOCUMENT, so a document is pointed at wherever it lives and
goes to the directory showing; SAVE FILE is ACTION_CREATE_DOCUMENT for the
selected row.

Both stage through the application's cache, and that copy is a requirement
rather than a shortcut. android-port.md predicted a picked document would be a
third IRemoteFileStore beside SFTP and S3; it cannot be. FileTransferQueue seeks,
because an upload resumes from the byte the last attempt reached, and a
content:// URI has no path behind it, no length worth trusting, no promised seek
and no grant that survives the document being edited underneath it. Copying
first costs one class in the head and nothing at all in the shared layers, where
the alternative was every resume rule rewritten around a stream that cannot
rewind. The copy is deleted when the transfer completes, kept while it is stopped
so RESUME still has something to read, and swept at the next launch — which is
the one moment emptying that directory is provably safe, since nothing has
queued anything yet.

Coming out had a decision going in did not: when to ask where it goes. The save
picker is raised before the transfer, so the download runs into the same staging
directory and hands its bytes to a callback the head supplied, held against the
transfer id so a RETRY still lands where the person pointed. Asking afterwards
would put the picker minutes from the button that caused it and, on a phone,
usually while the application is backgrounded and Android will not show one at
all. The cost is that the picker creates its file when it is dismissed, so a
download that then fails leaves an empty one there; that is said on the screen,
in the README and in the manual checks rather than left to be discovered. A
delivery that fails keeps the staged bytes for the sweep instead of throwing away
the one copy of something just fetched over somebody's network.

The foreground service counts transfers now, which is the half of it that
matters most here: a shell survives backgrounding because somebody is looking at
it, and an upload has to survive precisely when nobody is. Queued counts as
active, so putting five files in and locking the phone moves five files. The
seam was built for this and wired to () => 0 because nothing could fill the
queue.

Alongside it, ADR 0010 answers the second question android-port.md left open,
and it had to be answered before the first release rather than at upload time: a
new Play app must use App Bundles and therefore Play App Signing, and an
installed app can only be updated by a package signed with the same key, so the
first release picks an identity for good. The project holds the key, offline and
never in CI — the workflow's package step now says so where somebody would break
it — and a DodoSSH deployment never serves the client, because a download link on
your own server hands the binary that holds the plaintext to the party the whole
threat model is about.

The README's M1 gap note was stale in both halves and is replaced by what is
actually true: credentials have an editor and a REMEMBER tick, and the device key
registers into the TPM under a CNG policy that makes the consent dialog a
condition of using it. What is left is the floor rather than a gap — no TPM, or
no Windows, means the passphrase on every launch.
This commit is contained in:
2026-08-04 10:07:16 +02:00
parent 7b7fd7b2ef
commit ebb88c8ae4
13 changed files with 1032 additions and 59 deletions
+44 -13
View File
@@ -28,7 +28,9 @@ verified is that it compiles, links, packages, and carries the right natives.
**The shape agreed:** a **phone-first** client that is the keychain plus a **terminal**, with sessions and
transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it
is **one remote pane** with Android's document picker for moving files in and out.
is **one remote pane** with Android's document picker for moving files in and out. *It has since arrived,
both ways:* the pane, the queue, `ACTION_OPEN_DOCUMENT` going in and `ACTION_CREATE_DOCUMENT` coming out,
with the foreground service now counting transfers as well as shells.
**What was actually checked**, so the rest can be read with the right amount of trust:
@@ -132,7 +134,9 @@ mean the transfer queue's local half (`LocalDirectory`, the drive list, the brea
code.
Note what *does* carry: `FileTransferQueue` itself, and `IRemoteFileStore` — Phase 6 already proved that
seam holds two very different remotes, and a `Uri`-backed Android document would be a third.
seam holds two very different remotes, and a `Uri`-backed Android document would be a third. (**⚠️ That last
clause is wrong, and the build is what corrected it**: a document URI cannot resume, so what shipped stages a
copy and hands over a path. See the note under the decision below.)
**Decided: one remote pane and the document picker**, and out of the first scope. See
[Decisions](#file-transfer-when-it-comes-one-pane-and-the-document-picker).
@@ -256,6 +260,24 @@ and `IRemoteFileStore` both carry over unchanged. Phase 6 already put a bucket b
an SFTP host, so a picker-granted document is a third implementation of a seam that has been exercised twice.
What is desktop-only is the *left* pane — `LocalDirectory`, the drive list, the breadcrumb trail.
> **⚠️ Corrected by the build. Both directions shipped, and neither as a third `IRemoteFileStore`.** A document
> URI cannot sit behind that interface honestly: the queue seeks, because an upload resumes from the byte the
> last attempt reached, and a `content://` stream promises no seek, no stable length, and no grant that
> survives the document being edited underneath it. So `DocumentStaging` copies the chosen document into the
> application's cache and hands the queue an ordinary path — which cost one class in the head and *nothing*
> in the shared layers, rather than a third implementation of a seam and every resume rule rewritten to cope
> with a stream that cannot rewind. The copy is deleted when the transfer completes, kept while it is stopped
> so RESUME can read it, and swept at the next launch.
>
> **Outbound is the mirror image, with one decision the inbound half did not have to take: when to ask.** The
> save picker is raised *before* the transfer, so `QueueDeliveredDownload` runs into the same staging
> directory and hands the finished bytes to a callback the head supplied. Asking afterwards would put the
> picker minutes away from the button that caused it and, on a phone, frequently in the background — where
> Android will not show one. The cost is that `ACTION_CREATE_DOCUMENT` creates its file when it is
> dismissed, so a download that then fails leaves an empty one; that is said on the screen and in the
> README rather than left to be found. A delivery that fails keeps the staged bytes for the sweep instead of
> deleting the one copy of something that was just fetched over somebody's network.
### Sessions survive backgrounding, via a foreground service
A persistent notification for as long as a shell or a transfer is live.
@@ -562,9 +584,11 @@ What is left, in the order it matters:
- **Running any of it on a device.** Still the one that is not optional, and still true: nothing here has
ever been launched on hardware or an emulator. Everything below is reasoning from the code.
- **The document picker.** The files screen browses a remote, downloads from it and runs the queue; putting
a file *on* a host from the phone needs `ACTION_OPEN_DOCUMENT` behind a third `IRemoteFileStore`-shaped
source, which is the decision recorded above and the obvious next piece of work.
- ~~**The save picker — the other half of file transfer.**~~ **Built**, along with the half before it: both
pickers go through Avalonia's storage provider and both stage through the cache. See the correction under
the decision above for the shape and for the one thing about it a person will notice — an empty file where
a failed download was pointed. What is *not* built is a folder picker for several downloads at once: the
save picker names one destination, so SAVE FILE takes the selected row.
- **Editors.** There is no host editor and no keychain item editor on the phone, so both are create-on-
desktop-and-sync. That is why the v2 design's `+` buttons on HOSTS and on the keychain are not drawn.
- **Pins and import**, which v2 does not draw either. Teams *is* drawn, behind MORE — it was the one of
@@ -575,7 +599,9 @@ What is left, in the order it matters:
## Still open
Neither of these blocks the spike, and both want answering before there is anything to release.
Neither of these blocked the spike, and both wanted answering before there was anything to release. Both
now have answers, kept here under the questions that produced them — the heading stays because what is
worth reading is which way each went and why, not that the list is empty.
- ~~**Which Android versions.**~~ **✅ Settled: minSdk 28, targetSdk 36**, and the reasoning divided in two.
@@ -591,13 +617,18 @@ Neither of these blocks the spike, and both want answering before there is anyth
devices that mostly cannot hold a hardware-backed key anyway — which is the one thing the store is for.
API 28 and 29 still cost one branch each in `BiometricGate`, because allowed-authenticator lists arrived
in 30.
- **How it is distributed, and what that does to the supply-chain story.** ADR 0001 says plainly that an
operator who wants the secrets attacks the client rather than the crypto, and that release signing with a
key **not held by the server** is what that costs. Play App Signing means Google holds the release key.
That is not necessarily wrong — it is a different, and in some ways better-audited, trust arrangement —
but it is a change to a documented security property of this product, and it should be reasoned about in
an ADR rather than discovered at upload time. Sideloading a self-signed APK preserves the current story and
costs reach.
- ~~**How it is distributed, and what that does to the supply-chain story.**~~ **✅ Settled in
[ADR 0010](adr/0010-android-distribution.md): the project holds the release key, the deployment never
serves the client, and Play is deferred.** The question was whether Play App Signing Google generating
and holding the release key — is a change to the security property ADR 0001 documents. It is, and the ADR
takes it as a one-way door rather than a setting: a new Play app must use App Bundles and therefore Play
App Signing, an installed app can only be updated by a package signed with the same key, so the first
release picks one identity for good.
Two things follow for this head *now*, before there is anything to release. CI keeps signing with the
debug key and must never gain a keystore secret. And no download link for the APK may ever be served by a
DodoSSH deployment — that hands the client binary to the party the threat model is about, which is a
worse arrangement than either of the two the question was originally between.
## Smaller things, decided by default