Commit Graph
59 Commits
Author SHA1 Message Date
jaap-janandClaude Opus 5 a0d53b0c9d Give the phone its screens back by not stealing a data context
Signing in on the phone reached an unlocked shell showing PREFERENCES over the middle of the
screen, with a bottom bar whose four buttons did nothing. The buttons were fine. Every one
of them changed the shell's screen exactly as asked, and nothing moved, because an opaque
panel was sitting on top of the whole page area and never came down.

PendingScreen set DataContext = this in its constructor, so that Heading and Detail could be
written as plain bindings inside its own XAML. That is not a private arrangement: a binding
the parent writes ON one of these — IsVisible="{Binding IsPreferencesShowing}" in
PhoneShell — resolves against this control's data context, which was no longer the shell.
The binding looked for a shell property on a PendingScreen, found nothing, and left
IsVisible at its default. Its default is true. So the panel that says "this screen is not
built yet" was permanently visible, last in the Panel and therefore on top of the host list
and the keychain both — and the terminal underneath them.

The two properties are read with $parent now and the control inherits its context like every
other screen. There is no x:DataType on it any more either, so a plain binding here is a
compile error rather than a silently missing screen.

The desktop head has the other half of this lesson written down already: MainWindow gives the
terminal's IsVisible a FallbackValue precisely because an unresolved visibility binding does
not hide anything, it shows everything. That note was about the previewer. This is what it
looks like at runtime.

Verified by reproducing the mechanism rather than by reasoning about it: a control that owns
its data context ignores a parent's IsVisible binding and stays visible; one that inherits
obeys it. The head builds in Debug and Release. Nothing has been run on a device, as ever —
see docs/android-port.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:35:04 +02:00
jaap-janandClaude Opus 5 1db8bed872 Let a cancelled sign-in end the sign-in rather than the timeout
Backing out of the login page on Android left the shell showing "Opening your browser to
sign in…" with the button disabled for five minutes. Nothing was wrong except that nobody
told it: the redirect callback only ever completed when an intent arrived, so a user who
pressed back was waiting on OidcClient's browser timeout to expire before the flow failed
and the button came back.

There is no cancel event to subscribe to on this platform. Pressing back, dismissing the
browser and closing a provider's error page are indistinguishable from here — the browser
goes away and this application is foreground again with nothing delivered — so being
resumed while a sign-in is still waiting is the signal, and the only one there is. The
launcher records that a browser took the intent, OnResume fails the wait, and the guard
means the resumes that have nothing to do with signing in (a launch, recents, the
keystore's fingerprint prompt) go through untouched.

An exception rather than a cancellation, because OidcClient reads a cancelled wait as its
own timeout expiring and would report five minutes passing to somebody who waited two
seconds. It cannot steal a successful sign-in either: Android delivers the redirect to
OnNewIntent before resuming the activity, so the completion is already settled and the
attempt does nothing.

The enrollment key-binding trip through the browser is covered by the same change, since it
waits on the same callback.

The OnNewIntent remark had been sitting above OnResume, describing a method two below it.
Moved back, since the new remark wanted the space and the old one was wrong where it was.

Verified by building the head in Debug and Release. The behaviour itself is unverified for
the reason docs/android-port.md gives about this whole head: nothing has been run on a
device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 21:10:37 +02:00
jaap-janandClaude Opus 5 57d4b30557 Put the app's own mark on the launcher
ci / build and test (push) Failing after 3s
ci / api image (push) Skipped
ci / android head (push) Failing after 2s
The sign-in and locked screens both draw the same thing — a square outline in the accent
with >_ inside it — and the launcher was still showing the stock Android silhouette, so the
icon somebody taps and the icon the app opens onto had nothing to do with each other.

Redrawn as a vector rather than exported from the screen as a bitmap. There is one geometry
here and no set of density buckets to update four of and forget the fifth, and the accent
stays a number that can be diffed against Palette.axaml rather than a colour baked into a
PNG. The hex is written out because an Android resource cannot reference a XAML dictionary
— the same duplication colors.xml already carries for the window background, with the same
obligation attached.

Adaptive only, no raster fallback. Adaptive icons landed in API 26 and this head requires
28, so there is no device it ships to that would need the bitmaps; density buckets exist to
choose between PNGs and there is nothing to choose. The background layer is the same
@color/dodo_window as the window and the status bar, so the mark sits on the app's own
near-black rather than on a second one almost like it.

Two departures from the screen, both because a launcher is looked at much smaller than a
sign-in header. The box is 42 across rather than the 48 that first suggested itself: 72 of
the 108 survives masking, but that is a width, and a square meets a circular mask at its
corners — at 48 they land 33.9 out against a radius of 36 and read as clipped despite
technically clearing it. And the strokes are 2.2 and 2.8 where proportional fidelity to a
1px border on 44px would be 1.0, which a launcher drawing this at 48dp would render as half
a pixel of nothing.

A monochrome layer too, for the Android 13+ themed-icon setting. Without one a launcher
with themed icons on falls back to the full-colour icon, which would leave this the single
green thing on an otherwise recoloured home screen.

Verified in the packaged APK: the icon resolves at all five densities, the three layers
resolve, and the compiled vector carries the geometry above. The launcher rendering itself
was checked against local renders under circular and squircle masks at 144 and 64 px, not
on the device — the phone was locked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:46:40 +02:00
jaap-janandClaude Opus 5 215e73b07f Let the phone's theme past the activity it is attached to
The head has now run on a device, and the first thing it did was die on the way up.
DodoTheme parented @android:style/Theme.Material.NoActionBar, but AvaloniaMainActivity
descends from AndroidX's AppCompatActivity, which asserts its own theme attributes while
inflating and throws — "You need to use a Theme.AppCompat theme (or descendant)" — before
a single Avalonia frame exists. The platform's own parents are the ones that look right,
which is why the audit read as correct and the launcher icon still opened onto a splash
screen and then nothing.

Theme.AppCompat.NoActionBar instead, dark rather than .Light because every override below
it repaints the window near-black regardless. The no-action-bar and status-bar decisions
those overrides carry are untouched, so the reason they are there — a header that has to
hold the vault name, and a clock that would otherwise be dark-on-dark — still holds.

Verified on a OnePlus CPH2765: builds, deploys, and reaches the sign-in screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:16:56 +02:00
jaap-jan 4300d917a8 Stop making people wait for a handshake, and give the host list a pointer
ci / build and test (push) Failing after 3s
ci / android head (push) Failing after 2s
Connecting held the vault's busy gate, which meant a window that did nothing visible for
as long as a machine took to answer — and against one that is merely asleep, that is the
whole timeout. The gate is gone from that one command. A tab now appears in the strip in
the same turn as the click, carrying "connecting…" rather than a pane, and the terminal's
rectangle draws a card naming the host and the address being dialled. Every other screen
stays usable, and two connections can be in flight at once.

That splits the vault's one connection event into three, carrying an attempt id, because
"which tab is this about" can no longer be answered by "the most recent one". The id also
buys the two kinds of not-connecting their different endings: a refusal stays in the strip
as a tab holding its reason, since by then the user is quite likely three screens away and
a status line they are not looking at is not where a failure should end; a host key
question takes the tab away and puts the window back on HOSTS, because the prompt is drawn
there and a tab claiming failure would be competing with the thing about to resume it.

ConnectAsync takes no CancellationToken any more, and that is load-bearing rather than
tidying. A [RelayCommand] over a method that takes one generates a command that cancels
the previous execution's token on every invocation — so asking for a second machine
silently abandoned the first, measured as the first tab disappearing with "Cancelled." the
instant the second was asked for. Giving up on a connection is closing its tab, and a
session that lands after that is adopted rather than dropped: a shell running with nothing
naming it cannot be closed at all.

A tab is marked active on IsShowing rather than IsSelected. The selection survives
navigating away — that is what makes the strip a way back to a terminal instead of a way
to lose one — so a tab lit while preferences filled the window was a second "you are here"
mark pointing at something nobody could see. The nav rail's own entries have always made
this distinction.

The host list grows the two gestures it looked like it already had. A right click selects
the row under the pointer before opening a menu of Connect, Edit and Delete — the menu is
on the list rather than in the item template, so its entries are the vault's own commands
and not a row's, and it is cancelled outright over a group heading. Dragging a host onto a
heading files it there, onto a host files it beside that one, and onto UNGROUPED takes it
out of a group; the write is one field of one host through the same repository a save
uses, refused while the editor is open because a drop is a gesture on the list and not on
a half-typed form.

Clicking a result in the palette connects, which is what a list of hosts under a search
box looks like it does. It went through the shell's own command, so the pointer and Enter
take one path.

And the files screen's two pickers followed the vault's lists once, at unlock: a host or a
bucket created afterwards could not be picked until the keychain had been locked and
opened again, with nothing on screen explaining why the machine plainly in the host list
was missing. They follow the collections now, re-finding the selection by id across the
rebuild a sync pass causes every minute.

165 shell tests and 69 layout tests green, including the connecting tab, both failure
endings, two connections at once, a connection in flight across a lock, and the right
click acting on the row under the pointer rather than on the selection. The drag itself is
in docs/manual-checks.md with the rest of phase 7 — headless Avalonia has no platform
drag, and a test that claimed to have dropped something would pass while confirming
nothing.
2026-07-31 22:59:33 +02:00
jaap-jan 7a3a521c59 Give the phone the rest of its screens, and a way in
ci / build and test (push) Failing after 2s
ci / android head (push) Failing after 1s
All seven screens of the design, plus the two it does not draw because it starts at an
enrolled phone: naming a server, and choosing a passphrase.

The five states docs/android-port.md worried about losing at 360dp are all here and none
of them softened. The changed-key refusal is a full-screen panel rather than a bottom
sheet, because a sheet is swipe-to-dismiss by convention and that screen must have no way
forward. The recovery code raises FLAG_SECURE for its own state and lowers it afterwards,
so the sentence about screenshots is true rather than decorative. The delete
confirmations keep their counts and replace the row in place.

Signing in works, and the seam it needed is worth more than the implementation:
IAuthorizationCallback now sits between OidcClient and the loopback listener, so the two
heads differ in where the response arrives and in nothing else. PKCE, the state check,
discovery, the token exchange and the key binding stay one implementation — a second OIDC
client would be a second place for a security bug to live. The phone registers a
private-use scheme with the system rather than binding a loopback port, which on a shared
device any other app can do first.

The accessory key row needed TerminalWorkspace.SendInputAsync: ordinary typing goes from
the renderer straight down the socket, and there was no way in for the keys a software
keyboard does not have. Ctrl latches, because one thumb cannot chord, and the latch is
drawn — a modifier that is on and does not look on is how somebody sends ^L to a database
prompt believing they typed an l.

597 client tests green, including two new ones for the input path and one for the
terminal surface command. Nothing has run on a device.
2026-07-31 21:43:11 +02:00
jaap-jan 81e7e6d939 Write down what the phone found, and stop it rotting
docs/android-port.md was an audit of work not started; it now says what is built. Three of
its statements needed correcting rather than extending, and they are marked where they sit:
the Android version question is settled and was never as open as it looked, because
Avalonia.Controls.WebView ships only a net10.0-android36.0 assembly and nothing lower can
resolve it; cleartext to loopback has to be permitted explicitly, which the audit missed
entirely; and the spike produced a structural change it did not anticipate, in
DodoSSH.Client.Shell.

A CI job of its own, because the head is deliberately not in DodoSSH.slnx and a project
outside the solution is a project nobody notices breaking. It packages as well as builds:
a native library with no Android ABI and an assembly that will not dex are both invisible
to a compile, and both are exactly what this head is exposed to.

The README says plainly that signing in is not built, that a fingerprint re-enrolment
destroys the device key, that a notification appears while a shell is open, and that none
of it has run on a device.
2026-07-31 21:09:42 +02:00
jaap-jan 2caedd93ff Merge branch 'main' into the Android head
Main grew the screens the host-management plan called for — hosts, pins, snippets, logs,
import, teams — plus the ObjectStore and Import projects behind two of them, and moved
WindowsDeviceKeyStore into the desktop head's Platform folder.

Five of those view models landed in a directory this branch had already moved, so they
join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the
namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android
head then gets transitively and will use neither of at first — scoped storage means there
is no ~/.ssh/config to import, and file transfer is out of its first scope.

Desktop suites green at 155 and 64.
2026-07-31 21:03:22 +02:00
jaap-jan fe9d7fc289 Give DodoSSH a phone, and a shared shell for both heads to drive
The Android head from docs/android-port.md, taken as far as its step 6.

Step 3, the spike, is answered and its throwaway screen is gone: libsodium.so and
libe_sqlite3.so are both in the arm64 APK, so NSec resolves its native half on Android
despite shipping no Android build, and the local cache opens. Two findings the audit
could not have had: Avalonia.Controls.WebView only ships net10.0-android36.0, which
settles the open "which Android versions" question at targetSdk 36; and Android has
blocked cleartext HTTP since API 28, so the terminal renderer needs a network security
config scoped to 127.0.0.1 or the WebView loads nothing.

DodoSSH.Client.Shell is new and is why the phone can exist: the view models, the terminal
renderer files and the palette moved there so both heads drive one state machine and draw
from one set of tokens. The desktop head is otherwise untouched and its 144 tests still
pass.

The platform pieces behind interfaces that already existed: the profile directory from
filesDir, a device key wrapped by a StrongBox-backed key that a fingerprint releases, and
a foreground service so a shell outliving a vault lock stays true on a platform that
stops backgrounded processes.

Sign-in is deliberately absent rather than approximated. It needs an app link, because
reusing the desktop loopback listener is the attack RFC 8252 section 8.3 names.
2026-07-31 20:58:48 +02:00