Public Access
2d95366552b387445a8fa8b5440d0bc9b97c389e
15
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
aff776cb42 |
Rebuild the terminal's keypad, and fix what a device run showed
Six defects, all found by running this head on an emulator against a local API rather than by reading it. They are in one commit because two of the files carry several of them and splitting those would mean hand-editing patches; each is argued at its own site. ── the keypad, which is most of this ─────────────────────────────────────────────────────────────────── It was eleven 30-by-44 keys in a horizontal ScrollViewer, and it was wrong twice over. A pan and a press begin with the same few pixels of travel, so a thumb coming down at an angle — which is how a thumb comes down — was read as the start of a pan and the press was never delivered; the row slid slightly instead. And 30 tall is well under the 48 Android asks for, so the presses that did arrive were hard to aim. The arrows, the keys pressed most and in sequence, were the ones parked past the right-hand edge. Termux, Termius, JuiceSSH, ConnectBot and Blink all ship this row and not one of them scrolls it: they divide the width evenly and add a second row when the set does not fit. That is not convention for its own sake — a key whose position is fixed can be hit without looking, which is the whole point of a modifier row used while reading the output above it. So: two UniformGrid rows, every key an equal share of the width, nothing scrollable, 40 tall. Every key kept. Arrows on the lower row where the thumb rests, reordered to ← ↓ ↑ → so the pairs that undo each other are neighbours. It costs about 51 pixels of terminal, four or five lines, and that is the right trade: the row that was cheaper did not work. The keys also gain a pressed state, which they had none of. They are Focusable="False" so that using one does not take the keyboard off the terminal, and a button that never takes focus never shows the adorner Fluent would otherwise give it — leaving no feedback at all on a control whose only job is to be pressed. Their looks move from assignments in the code-behind to Button.hotkey, which is what lets a style say :pressed, and the Ctrl latch becomes a class rather than two brushes written by hand. ── and five smaller ones ──────────────────────────────────────────────────────────────────────────────── The keyboard's inset was never cleared when the app was resumed without the keyboard it was applied for. Android takes the keyboard away as the activity stops and Avalonia's input pane raises no StateChanged, the pane going with the window rather than closing in front of one — so the interface came back holding a whole keyboard's height of empty canvas. 838 of 2340 pixels on a Pixel 5, on every screen, until something opened and closed the keyboard. MainActivity now raises PhoneEnvironment.Resumed and PhoneShell re-asks the pane. It asks rather than assuming zero: a permission prompt or the biometric sheet can hand the activity back with the keyboard genuinely still up, and flattening the inset there would strand the focused box under a real one. TextBlock.detail never wrapped, where .body beside it always has. A block that neither wraps nor trims lays out at its full single-line width and the parent clips it, with no ellipsis to say anything is missing — so the add sheet read "A machine to connect to, filed under a group if you" and stopped. Which of the fifty-odd .detail blocks overflows is a function of width and font scale rather than of the string, so this is a style rather than an attribute on the handful visibly wrong on one phone. The nine that genuinely want one line now say NoWrap beside their TextTrimming, which is the pairing that was always meant. The two port boxes are NumericUpDowns and were the only unthemed controls on this head — Fluent's border and fill, next to a TextBox.field sharing their row. App.axaml has styled NumericUpDown for the desktop all along; this head never had one. Three buttons classed "icon accent" were painting Fluent's own Button.accent, #6275AC, a blue in no palette here. The desktop overrides that style and this head did not, so the same class read correctly there and not here. The host key sheet said "First contact with 10.0.2.2 · 2222 . Nothing in this keychain…" — Avalonia puts a space between adjacent Runs and the last one began with the full stop. A gap before a full stop, on the one dialogue whose whole job is to be read carefully before it is answered. ── checks ────────────────────────────────────────────────────────────────────────────────────────────── manual-checks gains 10.4a and 11.10f. 10.4 already covered dismissing the keyboard with back or the chevron but not leaving the app with it up, which is the path the inset bug shipped through; 10.4a covers both directions, since a fix that flattened the inset on every resume would pass one half and fail the other. 11.10f guards the keypad against a ScrollViewer being put back around it. |
||
|
|
c0dd5a147e |
Give the phone's terminal its own keyboard, and stop refereeing a focus fight
Typing into a shell on the phone has been the worst thing about this head, and
the reason is not the renderer. It is that the keyboard over the terminal was
configured by Chromium rather than by us: a prose keyboard, with a suggestion
strip, autocorrect, autocapitalisation and a predictive layer that inserts text
ahead of the cursor — over a surface where every character is a command.
The obvious fix does not exist. xterm.js already sets every hint the web
platform has on its helper textarea — autocorrect, autocapitalize and
spellcheck are all off in the vendored bundle — and Android ignores all three,
because they are advisory. What actually silences a keyboard on this platform
is an inputType carrying TYPE_TEXT_VARIATION_VISIBLE_PASSWORD, which is what
Termux's own TerminalView sets and the reason typing in Termux feels like a
terminal. A page inside a WebView cannot reach that field: Chromium derives the
whole EditorInfo from the focused DOM node, and no attribute produces the
visible-password variation.
Avalonia can reach it, on the version already pinned here. Decompiled from
Avalonia.Android 12.1.1, AndroidInputMethod.OnCreateInputConnection:
if ((int)outAttrs.InputType == 1 && options.ShowSuggestions == false)
outAttrs.InputType = (InputTypes)(outAttrs.InputType | 0x80080);
0x80080 is VISIBLE_PASSWORD | NO_SUGGESTIONS — Termux's pair exactly. So the
keyboard behaviour wanted is one attached property away from any Avalonia
control, and permanently out of reach of the WebView. That asymmetry is the
whole decision; ADR 0015 records it, along with the two stages deliberately not
taken and what would falsify the premise.
◆ THE INPUT PATH LEAVES THE WEBVIEW. THE RENDERER DOES NOT MOVE.
TerminalInputSurface is a 1x1 focusable control that draws nothing and is where
every character typed into a shell now arrives. It is not stretched over the
renderer, which would be the obvious shape and is wrong three times over: the
WebView composites above everything Avalonia paints so it would be invisible,
it would never see the touches the WebView consumes first, and it would sit
over the connect page sharing that Panel whenever no session is open. Focus
does not need area.
Its IME client retains nothing and claims nothing — no surrounding text, no
preedit — because a terminal has no document for an input method to edit. The
line being edited is on the far side of an SSH channel and this client has
never been told what is on it.
◆ THE FOCUS FIGHT ENDS BY REMOVING A CONTESTANT.
TerminalFocus is deleted rather than amended. It existed to hand Android's own
view focus back to the WebView after AvaloniaView.DispatchTouchEvent took it at
both ends of every touch, and it was correct for as long as the input
connection belonged to the page. It no longer does, so the focus the platform
hands to Avalonia's view is now the focus the keyboard wants. The accessory
row's two posted counter-moves go with it; the keys stay Focusable=false, which
is now what keeps Avalonia's focus on the input surface.
RendererFocus replaces it with the opposite move: the WebView is made
non-focusable, so it cannot take an input connection and Chromium's own
showSoftInput cannot succeed from it. Non-focusable is not non-interactive —
focusability governs keyboard input, not touch delivery — so the page keeps its
pan-y scrollback untouched. What it can no longer do is raise a keyboard, so
taps on it are observed with Handled=false throughout and reported, filtered by
the platform's own ScaledTouchSlop. Without that filter, dragging the
scrollback would raise the keyboard over the output being scrolled to.
◆ TWO THINGS FOUND ON THE WAY, ONE OF THEM OLD.
The Ctrl latch never worked with the software keyboard. Not a regression and
not new — everything typed went straight into the page and never passed
SendAsync, which is where the latch lives, so `ctrl` then `c` was inert unless
the second key also came from the accessory row. Routing input through Avalonia
fixes it as a side effect, and check 11.10d exists to keep it fixed.
Avalonia raises a text-input event for any key whose UnicodeChar is 32 or
above, whether or not the key event was handled — it reads the handled flag
only to fold into its own return value. Ctrl-C on a hardware keyboard therefore
produced 0x03 and then a literal c. controlByteAlreadySent swallows the second.
The keys in TryTranslate need no such guard, their UnicodeChars being below 32.
One deliberate loss, in the open: Delete sends backspace. Avalonia turns an
IME's deleteSurroundingText into a dispatched KEYCODE_FORWARD_DEL, so a soft
keyboard taking that route is indistinguishable here from a real Delete key —
and a backspace that deletes forwards on every phone is worse than a missing
forward-delete on the few devices that have one.
Nothing here is verifiable without a device, so the checks are written rather
than the tests: 11.10b is the falsification test and should be run first,
because everything staged on this stands or falls on whether one attached
property actually silences a real keyboard.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
9bc9069425 |
Post the terminal's focus return past the dispatch that steals it
The first fix handed Android's focus back from inside the keys' Click handlers — which fire inside the UP event's dispatch, and Avalonia's own view requests focus for itself after every handled touch dispatch returns (AvaloniaView.DispatchTouchEvent, decompiled from 12.1.1). So the platform's request ran after ours and undid it microseconds later, which is exactly what the phone showed: the terminal still lost focus. The return is now posted onto the main looper, landing one message after the dispatch that stole, and it is wired at the row for both halves of a press — DOWN steals too, and Click only exists for UP, so a keyboard detached at DOWN would otherwise stay detached for the whole length of the press. Check 11.10a now also says what a tolerable blink looks like against a failure that stays. |
||
|
|
e936ab4646 |
Announce a session's end when it is actually over, and for closes too
The phone's notification kept saying '1 shell connected' after the shell was gone, and both close routes were at fault. A shell exiting on its own raised SessionEnded from inside its run's finally block — where the run task is by definition not yet complete, so the LiveSessionCount the keep-alive reads still counted the dead shell, and nothing fired later to correct it. A tab closed by hand announced nothing at all, by a recorded decision that assumed every subscriber was the closer; the keep-alive is not, and a close it never heard about left the notification claiming a shell over nothing. The end is now announced from a continuation after the run completes, and CloseSessionAsync announces after its own drain — every subscriber was already a reconcile-to-reality handler, so the echo the old remark feared costs nothing. Shutdown stays silent: it is dismantling the subscribers along with the sessions. |
||
|
|
506d2803a2 |
Hand Android's own focus back to the terminal after an accessory key
Focusable=false was only ever half the fix, and its remark now says so: Avalonia's focus stays on the NativeWebView, but the touch that presses a key still hands Android's native focus to Avalonia's input view — the platform moves it before Avalonia decides anything. The WebView's input connection dies with it, the keyboard swaps to its no-input layout, and the inset churn parks it over the very row that was tapped. Each key now returns that focus once its byte is on the wire, through a sibling of SoftKeyboard that walks the decor view to the one WebView this application has. Free when nothing moved. Check 11.10a is the phone-in-hand proof. |
||
|
|
48ea5e22d5 |
Actually keep the phone's sessions alive when the app is backgrounded
The foreground service existed, and four defects in its wiring meant it mostly did not run. A shell opening was never announced to it — only the ending was — so the service never came up for a shell at all. An idle connected Files session counted as nothing. Every refresh restarted the service, which Android 12+ answers with a crash the moment the app is backgrounded — a transfer finishing in the pocket took the remaining connections with it. And POST_NOTIFICATIONS was declared but never requested, so on Android 13+ the receipt was silently invisible. Updates while backgrounded now go through the notification manager; a foregrounded refresh still prefers a real start, so a stop still in flight cannot leave an orphan receipt over an unprotected process. |
||
|
|
ca7fee2358 |
Start the confirmation Android hands back, so an update can install
Pressing INSTALL closed the application, installed nothing and said nothing. That is two independent faults in one method, either of which breaks it on its own, and they hid each other: the first kills the process before the second can be observed, and the second is silent by construction. The pending intent handed to commit was implicit — an action string with no component behind it. A mutable pending intent may not wrap one of those from API 34, and this head targets 36, so every current phone threw IllegalArgumentException before commit was reached. Nothing caught it, so it left the command handler, passed the dispatcher and took the process with it. That is the closing. Below 34, where it did not throw, it still installed nothing. An application holding REQUEST_INSTALL_PACKAGES rather than the privileged INSTALL_PACKAGES gets no verdict back from a commit: what the platform answers first is STATUS_PENDING_USER_ACTION, carrying the activity that draws the dialogue in EXTRA_INTENT for the application to start. Android does not draw it on its own. The comment here asserted the opposite — that a pending intent is required whether or not anything listens, and that nothing needed to — so no receiver was ever written, and the session was written, committed and left staged forever. So there is a receiver now, not exported because the only sender is this application's own commit, and the intent naming it is explicit, which is the same change that stops the throw. Sessions are abandoned when anything fails, since one created and neither committed nor abandoned stays staged against a per-application cap — a repeating fault would have started failing at CreateSession instead, which is the same bug wearing a completely unrelated face. The reporting is the part worth keeping even after the cause is gone. Where applying ends the process an exception has nowhere to go; where it does not, which is this head's whole shape, it goes out through the dispatcher. RestartNowAsync now answers the way CheckNowAsync already did, and the regression test asserts the absence of a throw rather than the presence of one. ADR 0014 rule 6 gets the correction in place: "asks Android to ask" is one step longer than it reads. Check 17.5 needed no rewording — it asks for the installer appearing by name, which is exactly the thing that never happened — so what it gets instead is the two symptoms named, because both present as a dead button. It is the only thing in the project that can catch either, and it plainly was never run against a real pair of builds. Note for whoever takes the next nightly: a broken updater cannot install its own fix. The phone is running the code this commit replaces, so the first build carrying it has to be sideloaded by hand; the ones after that install normally. Compile-verified and manifest-verified — the receiver reaches the generated manifest — and 321 tests pass. Not run on a device, which is what 17.5 is for. |
||
|
|
253c72d2b7 |
Name the organisation the repository actually lives in
It moved to DodoTech-Public, and every address in the product still said DodoTech. That looked like it worked, which is the part worth writing down: Gitea leaves a 301 at the old path and HttpClient follows a redirect on a GET, so both update channels would have kept polling through it. What a 301 does not survive is a POST. `vpk upload gitea` publishes the desktop release by POSTing to that URL, so the stale address would have failed at the one step the whole feature depends on — and a redirect is a thing an operator can delete, which turns "works today" into the same silent outage this session has already spent two commits on. So both channel constants, both release scripts, the workflow's REPO, the image's source label and the curl in phase 16 all name the live path. The local remote too, which had been printing a redirect warning on every push. Measured after the move: the org, the repo and the nightly release all answer 200 anonymously, and that release now carries both assets — the manifest and a 54 MB APK. The upload going through also answers the open question about the reverse proxy's body-size limit, which nothing local could test. |
||
|
|
9a7e3bbd5c |
Let a failed update check say so, instead of reporting good news
The phone reported every build as current because the release repository is private. Gitea answers 404 rather than 403 for a repo you cannot see, the client reads that address anonymously, and AndroidUpdateChannel caught the failure and returned null — which IUpdateChannel documented as meaning "this build is the latest". The check had never once succeeded on any phone and nothing anywhere said so. Two faults, and the second is why the first lasted. The seam said null was the honest answer for an unreachable channel, on the reasoning that the caller does the same thing either way. That is true of the six-hourly pass and false of CHECK NOW. UpdateViewModel already draws the line correctly — silent on the timer, the exception's message on the button — and it could only ever draw the first half, because nothing was ever thrown at it. The desktop's channel does not catch, so the interface described neither implementation. So CheckAsync throws now, and null means one thing. A release that is reachable but missing its manifest or the APK it names throws too: "you are up to date" about a half-published feed is the same lie in a smaller costume, and the self-healing that argument protected is untouched, since the timer still swallows everything. The precondition is written down where somebody would look, rather than left as a sentence about where a token could live. ADR 0013 §4 already said a private release repository was incompatible with this design; nobody checked which side of it this repository was on. It is one curl, and manual-checks phase 16 now opens with it — pointedly not against /api/v1/version, which answers 200 from a forge that is up whatever is readable on it, and which is what made this look like nothing was wrong. Phone check 17.4 was the one that passed all along. It now presses CHECK NOW with the network off as well as on, because two different answers are the whole of what makes the first one worth reading. |
||
|
|
b4a6c19ac1 |
Let the phone replace itself, and give CI a channel it may sign
The Android head had no updater and no release path, and the two are one problem: Android refuses an update signed by a different key, and CI generates a fresh debug key in every container. An APK released from a workflow could be installed once and never updated again — each new one an uninstall, which on this product means losing the cache, the outbox and the device key. So there are two channels, and they are two applications because the platform gives no third option. dev.dodotech.dodossh is cut from a v* tag by a person running scripts/release-android.ps1 with the key ADR 0011 rule 1 keeps off runners. dev.dodotech.dodossh.nightly is cut from main by CI and signed with a keystore committed here in the open — a key everybody has cannot be stolen and grants nothing by being held, which is why putting it in CI does not touch the rule. Neither can update the other, by construction. See ADR 0014. The android job assumed an image with a JDK and an Android SDK on it, which is what a GitHub runner is and what this project's is not. It now installs a JDK, fetches Google's command-line tools, accepts the licences and installs API 36 — each a no-op where it is already satisfied, and each cached by the persistent runner's own disk rather than by an action that would move a quarter of a gigabyte to rebuild a directory that never left. The client reads a small JSON manifest beside the APK, the counterpart of releases.win.json, and compares Android's versionCode rather than a version name: that integer is what the platform itself uses to accept or refuse an install, so comparing anything else would offer updates the phone then rejects. It fetches, and then asks Android to ask — the system draws its own confirmation, and from API 26 will not draw even that until unknown sources is on for this application. IUpdateChannel gained ApplyingEndsTheProcess. On Windows applying replaces the files and restarts, so the shell disposes the vault first and that is what zeroes the keys. On the phone the install is a request and the answer may be no, so disposing first would answer "not now" with a locked keychain and every shell closed — a punishment for declining an update. Two measured bugs found on the way, both older than this work and both invisible to a -getProperty check. ApplicationDisplayVersion is read by the Android targets in a top-level PropertyGroup, so the target setting it from MinVer ran after the only thing that reads it: every APK ever built here said versionName 1.0.0. And nothing found so far varies the launcher name per channel — four mechanisms tried, all of them recorded in platform-flags, none of them reaching the label the launcher shows. The two channels share an icon name for now and are told apart by package name, version, and what the preferences screen says. |
||
|
|
ebb88c8ae4 |
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. |
||
|
|
f5ffd1983d |
Make Connections the place a connection is made, and put the keychain away
Four changes to the phone, and the last one needed the connect path taking apart. **The bottom bar is three entries.** The keychain moved onto the hub, which is now SETTINGS with a gear rather than MORE with a hamburger. A bottom bar is for the places a session moves between, and keys, credentials and tags are managed occasionally and then left alone — which is the shape of everything already behind that hub. With the keychain on it, "more" stopped being a description of what is there. `ShellScreen.Vault` joining `IsMoreSurface` is the whole of the change: the tab that lights, the header that stands down and the back gesture's first case all read that one property, which is why the switch mirrors it by construction rather than by a second list. The keychain screen grew the header every hub screen has, because the shell's own is not above it any more and without one there would be no back arrow and nothing saying what the list is. The desktop keeps its Keychain rail entry. A rail with nine slots has room, so this is the second thing the two heads arrange deliberately differently, after the hub itself. **Terminal became Connections**, and the word does more work than a rename usually does — see below. The enum member stays `ShellSurface.Terminal`, for the reason the tab was never called Vault: the surface is a terminal, and the word a user reads is the product's. **The + puts the software keyboard away.** It sits above a terminal somebody is typing into, so the sheet it raises was arriving underneath a keyboard covering the half of the screen the sheet is on — and worse, laid out into the strip left above it, since the keyboard's inset shortens everything this head draws. Avalonia cannot do this and it is worth knowing why: `TopLevel.InputPane` reports the keyboard and offers nothing that closes one, because the framework's model is that it belongs to whatever has focus — and this keyboard was raised by the `WebView`'s own text input, by a native view Avalonia's focus manager never owned. Clearing Avalonia's focus leaves it exactly where it is. So `Platform/SoftKeyboard.cs` asks `InputMethodManager`, off the decor view's window token, and every step of it is allowed to be absent. **With nothing open, Connections is a connect screen rather than an empty state.** A box taking `user@host` or `user@host:port`, a password, and the machines most recently connected to underneath. The box is the only path in this product to a machine the keychain has never heard of, which is a real case it had no answer for: an address somebody was handed five minutes ago. A typed password and nothing else — offering the keychain's keys would be a second binding resolution beside `TryBuildAuthentication`, and the argument against a second one is written there at length. Nothing typed is saved, and the screen says so: a machine worth keeping belongs on HOSTS, where it can carry a key, a group's defaults and a name. The recents come out of the vault's own connection log rather than a list kept in this process, so they survive a restart and arrive on a new phone with the keychain. Deduplicated by address, because this is a list of places and not of events, and capped at six so the box stays above the keyboard. Emptied when the vault is — they are decrypted entries naming where somebody works, and a lock that left them on screen would be a list still readable after every key that decrypted it was zeroed. Tapping one leads to whichever of two things it is: a keychain host goes to that host's connect bar, where its key, its password box and its refusals already live, and an address goes back into the box, without the password, whose absence is the point of that path rather than a gap in it. **The connect path was shaped like `HostRowViewModel` all the way down.** The log entry, the identification, the failure record and the retry all took a row. They take a four-field `ConnectionTarget` now, so a connection to an address shares the ladder of refusals, the host-key question and the tab's lifecycle rather than growing a second copy of them. `ConnectionRecorder.Record` and `Identify` have always taken a nullable host id, so the log could already hold a connection with no item behind it. One behavioural change falls out of that and it is the one to know about: **trusting a host key now retries the attempt that raised the question** instead of re-running whichever host is selected. That was correct while a selected host was the only way to connect; with a manual target it would dial a different machine, or refuse with "choose a host first" over a key the user has just agreed to trust. The test selects a host first, so a regression cannot pass by connecting to the wrong thing successfully. `LogsViewModel.ReloadAsync` split so the connections half can be read alone. Reading the keychain's activity for a screen that offers neither would double the decryption on the list that was already the expensive one. Twelve tests: the parse grammar as a theory over seven refusals, the dialled request, the retry, and both branches of tapping a recent row. The recents rows are built by hand rather than connected-and-closed — what those tests are about is which branch a row takes, and driving it through the recorder's queue would test the recorder, which `DodoSSH.Client.Session.Tests` already does. What needs a device is phases 11.6 to 11.9 of `docs/manual-checks.md`. |
||
|
|
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> |
||
|
|
7a3a521c59 |
Give the phone the rest of its screens, and a way in
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. |
||
|
|
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. |