Public Access
Merge main into the teams branch
Two conflicts, and both were two people counting the same things differently rather than disagreeing about what the code should do. PhoneShell's header comment. The branch made "the five hub screens" numberless, because TEAMS made it six and a number in that sentence had already gone stale once. Main corrected "three destinations" to "two" in the same sentence, because giving a shell the whole phone took the terminal out of the set the header is drawn on. Both are right and neither noticed the other: the header now stays on the hub's screens and on the two top-level destinations, which is Hosts and Keychain. The manual checks. Both sides appended a Phase 10 — main added the software keyboard and the phone's terminal surface as 10 and 11, the branch added Teams. Nothing about them overlaps, so the resolution is to keep all three in the order they were written and renumber Teams to Phase 12, its subsections and the one cross-reference inside 12.1 with it. Main's two phases keep the numbers they already carry in its history, since renumbering those would move headings somebody may already have linked to. Everything else merged without a conflict, and the two places worth checking afterwards both held: IsMoreSurface and the first case of PhoneShell.OnBackRequested each kept ShellScreen.Team alongside main's edits. Those two are one fact in two places, so a merge that dropped Team from either would have trapped the user on the teams screen with the MORE tab dark. Verified after resolving: solution builds with no errors and no new warnings, the Android head builds, and every suite passes — App 214, Layout 73, Api 162, Infrastructure 34, Contracts 25, Session 54. App gained the three shell-flow tests main brought with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -331,6 +331,12 @@ that design now; the desktop's own v2 is a 190-pixel labelled sidebar in place o
|
||||
search box in the titlebar, and session tabs as pills. Its light theme is not built — see
|
||||
[`docs/design-import-gaps.md`](docs/design-import-gaps.md) — so the application is dark on both.
|
||||
|
||||
**A connected phone shows one bar and then the terminal.** The header, the session strip and the bottom
|
||||
bar are collapsed while a shell is up, and a single 52-pixel row replaces them: back on the left, the
|
||||
sessions as pills, and a `+` on the right offering the three connections this application can make — a
|
||||
shell, a host's files over SFTP, or a bucket. The system back gesture does what the arrow does, and lowers
|
||||
that menu first if it is open.
|
||||
|
||||
Widening the sidebar moved the desktop window's minimum from `880x560` to `1016x574`, which leaves every
|
||||
screen exactly the width it was designed against.
|
||||
|
||||
|
||||
@@ -509,6 +509,14 @@ go at 360dp:
|
||||
drawn, because a modifier that is on and does not look on is how somebody sends `^L` to a database prompt
|
||||
believing they typed an `l`.
|
||||
|
||||
**The surface has since taken the whole screen.** `PhoneShell` collapses the header, the session strip
|
||||
and the bottom bar while a terminal is showing — one binding on `IsShowingPages` each — and the screen
|
||||
draws a 52-pixel bar in their place: back, the session pills, and a `+` raising a sheet with the three
|
||||
connections there are. That sheet is the head's first control that could be drawn over the renderer, so
|
||||
it collapses it rather than covering it, exactly as the desktop's palette does; whether Android's
|
||||
WebView actually composites above Avalonia content is still the unverified question recorded below, and
|
||||
collapsing is correct under either answer.
|
||||
|
||||
All eight are done for the decided scope, and v2 has since gone past it — see the note under step 7.
|
||||
|
||||
What is left, in the order it matters:
|
||||
@@ -564,3 +572,16 @@ Recorded so they are choices rather than accidents. Any of them is cheap to revi
|
||||
- **`NativeKeyboardFocus` is not ported.** It exists for a documented Win32 asymmetry — focus crosses into
|
||||
WebView2 but does not come back — and Android's focus model is different enough that the problem should be
|
||||
confirmed to exist before anything is written to solve it.
|
||||
- **The software keyboard is kept off the interface in one place, and by two mechanisms.** `PhoneShell`
|
||||
owns it rather than each screen, because everything the phone draws is inside that one control and a
|
||||
screen added later would otherwise have to remember. The two mechanisms are not a belt and braces: before
|
||||
Android 15 the activity's `AdjustResize` has the platform shorten the window and the reported keyboard
|
||||
inset arrives already consumed, and from Android 15 edge-to-edge is enforced, the window is no longer
|
||||
resized for the keyboard, and the inset is what there is. Each is dead where the other applies, which is
|
||||
why the margin is taken from the inset alone — the two added together would strand the interface an
|
||||
entire keyboard too high. See `docs/manual-checks.md` phase 10, which is the only way either is verified.
|
||||
- **A box that takes a secret says so twice.** `PasswordChar` is what the screen draws and
|
||||
`TextInputOptions.ContentType` is what the software keyboard is told, and only the second one turns off
|
||||
the suggestion strip and keeps the passphrase out of the IME's learning dictionary. The desktop head
|
||||
needs only the first, which is why the phone's `TextBox.secret` class carries both rather than the two
|
||||
being set per box.
|
||||
|
||||
@@ -31,7 +31,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> | v2 element | What ships instead |
|
||||
> | --- | --- |
|
||||
> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Nothing, said out loud.** `ISshConnection` offers `OpenShellAsync` and nothing else, so there is no tunnel for a rule to run through; `SyncEntityType.PortForward = 9` is still reserved and still unused. The MORE screen carries a paragraph naming the absence, for the reason the desktop keeps TEAMS in its rail. |
|
||||
> | `23 ms · fwd 5432` on the terminal's connection line | The account and endpoint actually dialled. There is no RTT measurement in SSH.NET and nothing forwards anything. |
|
||||
> | `23 ms · fwd 5432` on the terminal's connection line | ◆ **The line is gone, and what was real on it moved.** There was never an RTT to draw — SSH.NET measures none — and nothing forwards anything, so what shipped was the account and endpoint actually dialled. In v3 a connected phone draws one 52-pixel bar and then the terminal, so a second 36-pixel row naming the machine is exactly the chrome that surface exists to give back: the address is on the connecting card, where it is read before anything has answered, and the shell's own prompt says it afterwards. The two text-size buttons that shared the line are pinned at the end of the accessory row, outside its scroller, which is what the line was protecting them from. |
|
||||
> | `ED25519` badge and `SHA256:kQ9f…Zw2M` on every keychain card | `Detail`, which is what is genuinely known *about* an item. Unchanged from the first import: no algorithm field, no fingerprint, and computing either means parsing armour the type stores verbatim. |
|
||||
> | An `agent` chip on a key | Omitted. There is no agent of any kind — see the first import's Vault section. |
|
||||
> | Snippet cards footed with `edge-eu-1 · today 10:58 · exit 0` | The command and its badge. Nothing records where a snippet ran, when, or what it returned; the shell it is typed into never reports back. |
|
||||
|
||||
+131
-11
@@ -835,7 +835,127 @@ On the phone's host editor with several tags: the chips are at least 36 tall, sp
|
||||
lands between them rather than on the wrong tag, and the new-tag box and ADD sit on one row without either
|
||||
being squeezed to nothing.
|
||||
|
||||
## Phase 10 — Teams: the operations that span two accounts
|
||||
---
|
||||
|
||||
## Phase 10 — The software keyboard and the boxes that take secrets
|
||||
|
||||
Every check here needs a real Android device or emulator, and there is no headless equivalent of any of
|
||||
them: the software keyboard is an inset the platform reports, and a headless top level reports none.
|
||||
Worth running on two devices if you have them — one on Android 14 or earlier and one on Android 15 or
|
||||
later — because the interface is kept clear of the keyboard by a different mechanism on each. Before 15 the
|
||||
activity's `AdjustResize` has the platform shorten the window; from 15 the window is not resized at all and
|
||||
`PhoneShell` applies the reported inset itself. A build that only ever ran on one of the two will look
|
||||
correct and be half broken.
|
||||
|
||||
### 10.1 The vault passphrase box is treated as a password by the keyboard
|
||||
|
||||
Launch to the lock screen, tap the passphrase box, type a few characters.
|
||||
|
||||
**Pass:** dots on screen, and **no suggestion strip above the keyboard** — no completions, no previously
|
||||
typed words, no autocorrect. Then open any ordinary box on the phone (the host search, a snippet's name)
|
||||
and confirm the suggestions come back there.
|
||||
|
||||
**Failure means:** `TextInputOptions.ContentType` is missing — most likely a box was given `PasswordChar`
|
||||
directly instead of `Classes="... secret"`. `PasswordChar` is what the screen draws; the content type is
|
||||
what the keyboard is told, and only the second one keeps a passphrase out of the IME's learning
|
||||
dictionary. A box showing dots with a suggestion strip over it is the worst case, not a cosmetic one.
|
||||
|
||||
### 10.2 The keyboard does not cover the box being typed into
|
||||
|
||||
The same box: with the keyboard up, the passphrase box and the UNLOCK button under it are both visible.
|
||||
Repeat on each of the five boxes that take a secret — lock screen, both enrollment boxes, the connect
|
||||
password on HOSTS, and the connect password on FILES.
|
||||
|
||||
**Pass:** the box stays on screen when the keyboard opens, and the interface is shortened rather than slid
|
||||
— the header stays where it is rather than scrolling off the top.
|
||||
|
||||
**Failure means:** on Android 15 or later, the inset is no longer reaching `PhoneShell`. On 14 or earlier,
|
||||
`WindowSoftInputMode` has been dropped from the activity and the platform is panning the window instead of
|
||||
resizing it — which, for a window whose whole content is one native view, pans by nothing useful.
|
||||
|
||||
### 10.3 Nothing is stranded when the keyboard closes
|
||||
|
||||
Dismiss the keyboard with back or the down-chevron from each of those screens.
|
||||
|
||||
**Pass:** the interface fills the screen again immediately, with no band of empty canvas left along the
|
||||
bottom and no scroll position left part way down.
|
||||
|
||||
**Failure means:** the inset is being applied but not cleared — the closed state is not being read from the
|
||||
event, or the margin is only ever added to.
|
||||
|
||||
### 10.4 Rotating with the keyboard up
|
||||
|
||||
Focus a passphrase box, then turn the phone sideways.
|
||||
|
||||
**Pass:** the box is still visible and still focused, and the shell is intact — the activity handles the
|
||||
rotation rather than being recreated, and live shells survive it.
|
||||
|
||||
---
|
||||
|
||||
## Phase 11 — The phone's terminal surface
|
||||
|
||||
Every check here needs a real device for the reason Phase 10's do, plus one of its own: the interesting
|
||||
question on this screen is whether a native `WebView` composites above what Avalonia draws over it, and no
|
||||
headless surface has a native view to answer with. `docs/android-port.md` still records that as unverified;
|
||||
11.2 is the check that settles it.
|
||||
|
||||
### 11.1 A shell gets the screen · **the important one for chrome**
|
||||
|
||||
Open a shell from HOSTS.
|
||||
|
||||
**Pass:** the vault header, the session strip and the four-entry bottom bar are all gone. What is left is
|
||||
one bar — a back arrow, the session pills, a `+` — and then the terminal down to the accessory keys. Press
|
||||
back: all three come back, the tab is still in the strip and its dot is still green.
|
||||
|
||||
**Failure means:** one of the three rows is not bound on `IsShowingPages`, or the terminal is being reached
|
||||
by a route that leaves `Surface` on `Page`.
|
||||
|
||||
### 11.2 The connect menu is not drawn over the renderer · **the important one**
|
||||
|
||||
With a shell showing output, press `+`.
|
||||
|
||||
**Pass:** the terminal's rectangle goes to the canvas colour and the sheet sits over it whole — scrim, three
|
||||
rows and CANCEL, every one of them tappable, none of them sliced down the left edge. Tap the scrim: the
|
||||
terminal comes back with its scrollback intact and the shell still running.
|
||||
|
||||
**Failure means:** `IsTerminalShowing` is not being cleared by `IsConnectSheetOpen` — or, if the sheet is
|
||||
sliced *despite* the rectangle going blank, something else in that Panel is still showing. A sheet that
|
||||
draws over live terminal output is the Android answer to the compositing question, and it means every
|
||||
future sheet on this surface has to collapse the renderer too.
|
||||
|
||||
### 11.3 Back lowers the menu before it leaves the terminal
|
||||
|
||||
With the connect menu open, use the system back gesture.
|
||||
|
||||
**Pass:** the menu closes and the terminal is still showing. A second back leaves the terminal for the
|
||||
screen it was opened over.
|
||||
|
||||
**Failure means:** the guard in `PhoneShell.axaml.cs` is below the surface check rather than above it, and
|
||||
one gesture is spending two levels.
|
||||
|
||||
### 11.4 The two end buttons cannot be pushed off the bar
|
||||
|
||||
Open six or more shells.
|
||||
|
||||
**Pass:** the pills scroll under a fixed back arrow and a fixed `+`; neither ever leaves the screen, and
|
||||
scrolling the pills to either end does not move them.
|
||||
|
||||
**Failure means:** a control was put inside the `ScrollViewer` rather than beside it.
|
||||
|
||||
### 11.5 The text-size buttons are always reachable
|
||||
|
||||
With a shell open, scroll the accessory key row to the far left and the far right.
|
||||
|
||||
**Pass:** `A−` and `A+` stay pinned at the right-hand end throughout, separated from the keys by the
|
||||
hairline, and both are at least 38 tall. At the smallest and largest sizes the one that can do nothing is
|
||||
visibly disabled rather than silently inert.
|
||||
|
||||
**Failure means:** they have been folded into the scrolling row — which is the arrangement the connection
|
||||
line existed to avoid, and the reason it could be removed at all.
|
||||
|
||||
---
|
||||
|
||||
## Phase 12 — Teams: the operations that span two accounts
|
||||
|
||||
The server's own rules are covered by the endpoint suite: teams are renamed, an archive is refused while a
|
||||
vault is in the way, ownership changes hands, and every branch of the invitation claim is driven with
|
||||
@@ -850,7 +970,7 @@ second DodoSSH profile means a second machine, a second OS user, or the same mac
|
||||
Whichever account plays the invitee **must not have signed in to this deployment before** — most of what
|
||||
follows is about what happens the first time it does.
|
||||
|
||||
### 10.1 An invitation becomes a membership at the invitee's first sign-in · **the one worth the most care**
|
||||
### 12.1 An invitation becomes a membership at the invitee's first sign-in · **the one worth the most care**
|
||||
|
||||
1. Sign in as `alice`, make a team, and open its invitations.
|
||||
2. Invite `bob@example.com` as a Member. **Nothing is sent, and nothing should look as though it was** —
|
||||
@@ -866,12 +986,12 @@ follows is about what happens the first time it does.
|
||||
opens.
|
||||
|
||||
**Failure means:** step 5 failing with everything else passing is almost always the `email_verified` claim
|
||||
— go to 10.2 rather than reading the invitation code, because the server is doing exactly what it should.
|
||||
— go to 12.2 rather than reading the invitation code, because the server is doing exactly what it should.
|
||||
Step 6 opening the vault *without* Alice sharing a key would be the far more serious failure: nothing on
|
||||
the server can wrap a vault key, so an item that decrypts after a membership change alone means a key
|
||||
reached that machine by a route this architecture says does not exist.
|
||||
|
||||
### 10.2 An unverified address claims nothing, and the log is the only place that says so
|
||||
### 12.2 An unverified address claims nothing, and the log is the only place that says so
|
||||
|
||||
In Keycloak's admin console, clear **Email verified** on the invitee *before* their first DodoSSH sign-in.
|
||||
Invite that address, then sign in as them.
|
||||
@@ -888,7 +1008,7 @@ can walk into their team. Stop there. If it stays pending after verifying, the c
|
||||
**access** token — check the provider's mappers, and set `Oidc:EmailVerifiedClaim` if it sends the claim
|
||||
under some other name.
|
||||
|
||||
### 10.3 An invitation can be withdrawn until it is taken up
|
||||
### 12.3 An invitation can be withdrawn until it is taken up
|
||||
|
||||
Invite an address, then revoke it before anybody has signed in with it. Then sign in with that address.
|
||||
|
||||
@@ -901,7 +1021,7 @@ accepted one that could be unpicked here would be worse: it is a membership now,
|
||||
revokes their vault key grants and flags every team vault for rekey, which is not what "revoke invitation"
|
||||
should quietly do.
|
||||
|
||||
### 10.4 An address already in the team is refused; an address that merely has an account is not
|
||||
### 12.4 An address already in the team is refused; an address that merely has an account is not
|
||||
|
||||
With Bob in the team, invite `bob@example.com` to it again.
|
||||
|
||||
@@ -915,7 +1035,7 @@ hour on his next request rather than at a sign-in, so give it that long before d
|
||||
endpoint has become a way of asking the server which addresses have accounts on it, answerable by anybody
|
||||
willing to create a team first. See ADR 0009.
|
||||
|
||||
### 10.5 LAST ACTIVE is a real time, and a coarse one · **needs a couple of hours**
|
||||
### 12.5 LAST ACTIVE is a real time, and a coarse one · **needs a couple of hours**
|
||||
|
||||
Use one account and leave the other idle for two or three hours, then read the members table.
|
||||
|
||||
@@ -928,7 +1048,7 @@ user's own overlapping requests failing on a version that moved under them. A va
|
||||
means the refresh is not running on ordinary requests at all, which is the state that made this column
|
||||
impossible to offer honestly before.
|
||||
|
||||
### 10.6 Ownership changes hands in one act
|
||||
### 12.6 Ownership changes hands in one act
|
||||
|
||||
As the owner, transfer ownership to another active member, then read both rows.
|
||||
|
||||
@@ -942,7 +1062,7 @@ member, and to yourself.
|
||||
either one leaves a team that no client can administer back into shape. If your grants were revoked or the
|
||||
vaults are now flagged for rekey, the transfer is removing the outgoing owner rather than demoting them.
|
||||
|
||||
### 10.7 Archiving is refused while the team owns a vault
|
||||
### 12.7 Archiving is refused while the team owns a vault
|
||||
|
||||
With a team that owns at least one vault, try to archive it.
|
||||
|
||||
@@ -953,7 +1073,7 @@ in this product. The team is still in everybody's list afterwards and its vaults
|
||||
everybody holding a key — including the person who pressed it, quietly, and with nothing in the product
|
||||
able to put them back.
|
||||
|
||||
### 10.8 Archiving an empty team takes its memberships and its invitations with it · **needs two accounts**
|
||||
### 12.8 Archiving an empty team takes its memberships and its invitations with it · **needs two accounts**
|
||||
|
||||
Make a team that owns no vaults, add the second account to it, invite a third address, and archive it.
|
||||
|
||||
@@ -965,7 +1085,7 @@ invitations inside the same transaction exists to prevent, and it would happen w
|
||||
nobody is watching. Note that taking the freed slug is correct rather than a defect, and is also the reason
|
||||
an archived team is only restorable by an operator who checks that first.
|
||||
|
||||
### 10.9 Renaming a team, and the slug that does not move
|
||||
### 12.9 Renaming a team, and the slug that does not move
|
||||
|
||||
Rename a team and change its description.
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ using DodoSSH.Client.Android.Platform;
|
||||
using global::Android.App;
|
||||
using global::Android.Content;
|
||||
using global::Android.Content.PM;
|
||||
using global::Android.Views;
|
||||
|
||||
namespace DodoSSH.Client.Android;
|
||||
|
||||
@@ -34,12 +35,26 @@ namespace DodoSSH.Client.Android;
|
||||
/// other launch mode answers it with a second copy of this activity on top of the first — which on this
|
||||
/// head would mean a second Avalonia application over a live one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><c>AdjustResize</c> is declared rather than left unspecified</b>, and it is half of how this head
|
||||
/// keeps the software keyboard off the box being typed into; <c>PhoneShell</c> is the other half. Left
|
||||
/// unspecified, Android chooses, and what it chooses for a window whose entire content is one native view
|
||||
/// — which is what an Avalonia surface is — is to pan: it slides the window up by however much it thinks
|
||||
/// the focused *native* view needs, and since that view is the whole surface, the passphrase box goes on
|
||||
/// sitting under the keyboard. Resizing instead makes the window shorter, which the layout inside it can
|
||||
/// answer, and a screen built around a <c>ScrollViewer</c> then scrolls the focused box into view by
|
||||
/// itself. On Android 15 and later this attribute is ignored — edge-to-edge is enforced there and the
|
||||
/// window is no longer resized for the keyboard — which is precisely the case PhoneShell handles from the
|
||||
/// reported inset. The two are complementary and never both in effect: where the window resizes, the
|
||||
/// keyboard inset arrives already consumed and measures zero.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Activity(
|
||||
Label = "DodoSSH",
|
||||
Theme = "@style/DodoTheme",
|
||||
MainLauncher = true,
|
||||
LaunchMode = LaunchMode.SingleTask,
|
||||
WindowSoftInputMode = SoftInput.AdjustResize,
|
||||
ConfigurationChanges = ConfigChanges.Orientation
|
||||
| ConfigChanges.ScreenSize
|
||||
| ConfigChanges.ScreenLayout
|
||||
|
||||
@@ -320,6 +320,27 @@
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Every box on this head that takes a secret, and it is a class rather than two attributes repeated five
|
||||
times because the two attributes are not interchangeable and both are needed.
|
||||
|
||||
<b>PasswordChar is what the screen shows; ContentType is what the keyboard is told.</b> The desktop head
|
||||
needs only the first — a Windows text box has no opinion about what is being typed into it. Android's
|
||||
software keyboard does: left at its default it treats a passphrase box as ordinary prose, which means
|
||||
the suggestion strip offers completions from everything that phone has ever been typed into, and the
|
||||
IME's own learning dictionary remembers what was typed. Dots on screen and a word-suggestion bar above
|
||||
them is the worst of both — the secret is hidden from the person typing it and offered to the room.
|
||||
TextInputOptions.ContentType is the property the Android backend maps onto InputType, and Password is
|
||||
what turns the suggestions off and keeps the entry out of the dictionary.
|
||||
|
||||
A field class is not implied. Two of the five boxes carry their own metrics — see LockedScreen — so this
|
||||
one sets nothing about size or colour, and the other three say Classes="field secret".
|
||||
-->
|
||||
<Style Selector="TextBox.secret">
|
||||
<Setter Property="PasswordChar" Value="•" />
|
||||
<Setter Property="TextInputOptions.ContentType" Value="Password" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The live dot, and the class name is the same one the desktop sidebar uses so the two heads cannot
|
||||
drift on what green means: a terminal is open on this host right now. Deliberately not reachability —
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
Text="This passphrase encrypts your vault on this phone and on the server. Nothing can recover it — not the operator, not a reset link. A recovery code follows, and losing both makes the vault unopenable." />
|
||||
|
||||
<TextBlock Classes="label" Text="PASSPHRASE" Margin="0,20,0,0" />
|
||||
<TextBox Classes="field" Margin="0,6,0,0" Text="{Binding Passphrase}" PasswordChar="•"
|
||||
<TextBox Classes="field secret" Margin="0,6,0,0" Text="{Binding Passphrase}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
|
||||
<TextBlock Classes="label" Text="CONFIRM" Margin="0,14,0,0" />
|
||||
<TextBox Classes="field" Margin="0,6,0,0" Text="{Binding ConfirmPassphrase}" PasswordChar="•"
|
||||
<TextBox Classes="field secret" Margin="0,6,0,0" Text="{Binding ConfirmPassphrase}"
|
||||
IsEnabled="{Binding !IsBusy}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding EnrollCommand}" />
|
||||
|
||||
@@ -92,8 +92,8 @@
|
||||
typed to open a terminal has not been offered here — and quietly reusing it would make a one-time
|
||||
password appear to work twice.
|
||||
-->
|
||||
<TextBox Classes="field" IsVisible="{Binding SelectedHostAsksForAPassword}"
|
||||
Text="{Binding TypedPassword}" PasswordChar="•" PlaceholderText="password" />
|
||||
<TextBox Classes="field secret" IsVisible="{Binding SelectedHostAsksForAPassword}"
|
||||
Text="{Binding TypedPassword}" PlaceholderText="password" />
|
||||
|
||||
<Button Classes="primary" Content="{Binding ConnectLabel}" Command="{Binding ConnectCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
|
||||
@@ -419,8 +419,8 @@
|
||||
Shown only for a host that actually asks for one. A password box beside a key-authenticated host
|
||||
is an invitation to type a secret nothing will use.
|
||||
-->
|
||||
<TextBox Classes="field" IsVisible="{Binding SelectedHostAsksForAPassword}"
|
||||
Text="{Binding ConnectPassword}" PasswordChar="•" PlaceholderText="password">
|
||||
<TextBox Classes="field secret" IsVisible="{Binding SelectedHostAsksForAPassword}"
|
||||
Text="{Binding ConnectPassword}" PlaceholderText="password">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding ConnectCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
|
||||
@@ -49,37 +49,31 @@
|
||||
the nearest thing to hand, and reaching past it to a button is the sort of friction that gets a
|
||||
phone client called slow.
|
||||
-->
|
||||
<TextBox Text="{Binding Passphrase}" PasswordChar="•" PlaceholderText="vault passphrase"
|
||||
Height="48" Padding="14,0" VerticalContentAlignment="Center"
|
||||
Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="6" Foreground="{StaticResource Text}"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="12"
|
||||
<TextBox Classes="field secret" Text="{Binding Passphrase}" PlaceholderText="vault passphrase"
|
||||
IsEnabled="{Binding !IsBusy}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding UnlockCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
|
||||
<Button Content="UNLOCK" Command="{Binding UnlockCommand}" IsEnabled="{Binding !IsBusy}"
|
||||
Height="48" Margin="0,10,0,0" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
Background="{StaticResource Accent}" Foreground="{StaticResource AccentInk}"
|
||||
CornerRadius="6" FontFamily="{StaticResource MonoFont}" FontSize="12" FontWeight="SemiBold" />
|
||||
<Button Classes="primary" Content="UNLOCK" Margin="0,10,0,0"
|
||||
Command="{Binding UnlockCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
|
||||
<!--
|
||||
Present only when this phone actually holds a device key. The design draws it unconditionally,
|
||||
but offering a fingerprint that cannot open anything is worse than not offering one — see
|
||||
AndroidDeviceKeyStore for the three ordinary ways it stops being available.
|
||||
|
||||
The label sets no font of its own: inside a Button.secondary it inherits the class's, which is
|
||||
what stops this one reading half a point smaller than every other outlined button on the phone.
|
||||
-->
|
||||
<Button Command="{Binding UnlockWithDeviceCommand}" IsVisible="{Binding CanUnlockWithDevice}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Height="48" Margin="0,8,0,0" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
Background="Transparent" BorderBrush="{StaticResource BorderMid}" BorderThickness="1"
|
||||
CornerRadius="6" Foreground="{StaticResource Text}">
|
||||
<Button Classes="secondary" Margin="0,8,0,0"
|
||||
Command="{Binding UnlockWithDeviceCommand}" IsVisible="{Binding CanUnlockWithDevice}"
|
||||
IsEnabled="{Binding !IsBusy}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="9">
|
||||
<Ellipse Width="16" Height="16" Stroke="{StaticResource Accent}" StrokeThickness="1.5"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="UNLOCK WITH FINGERPRINT" FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="11" FontWeight="SemiBold" VerticalAlignment="Center" />
|
||||
<TextBlock Text="UNLOCK WITH FINGERPRINT" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
@@ -93,10 +87,15 @@
|
||||
|
||||
<!--
|
||||
◆ The disclosure. Absent when there is nothing to disclose; never a card reading zero.
|
||||
|
||||
Not Border.card, and neither are its two counterparts on HOSTS and FILES: that class is a panel,
|
||||
and this is a warning, so the background and the border are the warn pair rather than the chrome
|
||||
one. What it does take is the radius the v2 ladder gives anything card-sized, which is what the
|
||||
other two already draw.
|
||||
-->
|
||||
<Border IsVisible="{Binding HasLiveSessions}" Margin="0,22,0,0"
|
||||
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="14,12">
|
||||
BorderThickness="1" CornerRadius="12" Padding="14,12">
|
||||
<StackPanel Spacing="6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<!-- Live, not Accent: this marks shells that are still running, which is a fact and not a
|
||||
@@ -116,12 +115,17 @@
|
||||
<!--
|
||||
The only answer to a forgotten passphrase, and it is deliberately the last thing on the screen and
|
||||
the only red one. Nothing can recover a passphrase; this empties the phone and starts again.
|
||||
|
||||
Two of Button.danger's properties are overridden rather than taken, and they are the two that would
|
||||
change what this control says. Elsewhere that class is a DELETE sitting beside a KEEP, where a red
|
||||
outline marks which of two offered actions costs something; here there is no pair, and a bordered
|
||||
red button under UNLOCK would read as the second thing to try rather than as the last. Borderless
|
||||
and Medium is what keeps it a way out rather than an offer. Everything else — the height, the
|
||||
centring, the colour, the type — is the class's.
|
||||
-->
|
||||
<Button Grid.Row="2" Command="{Binding SignOutCommand}"
|
||||
Margin="0,28,0,20" Padding="0,14" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center" Background="Transparent" BorderThickness="0"
|
||||
Foreground="{StaticResource Danger}" FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="10.5" FontWeight="Medium"
|
||||
<Button Grid.Row="2" Classes="danger" Margin="0,28,0,20"
|
||||
BorderThickness="0" FontWeight="Medium"
|
||||
Command="{Binding SignOutCommand}"
|
||||
Content="RESET THIS PHONE — forgot passphrase" />
|
||||
|
||||
</Grid>
|
||||
|
||||
@@ -22,9 +22,24 @@
|
||||
|
||||
The order is the design's rather than the rail's. Terminal sits second, beside Hosts, because those two
|
||||
are the pair a session moves between; on the desktop the terminal is not a rail entry at all.
|
||||
|
||||
── a terminal gets the screen ─────────────────────────────────────────────────────────────────────────
|
||||
Three of the four rows below stand down while a shell is showing: the header, the shells strip and the
|
||||
bottom bar itself. All three are bound on IsShowingPages, which is the same question asked once — the
|
||||
surface is either a page or a terminal, and these are the chrome a page has.
|
||||
|
||||
The arithmetic is why. Header 56, strip 46, bar 64, and the terminal's own two rows on top of that: at
|
||||
360dp the shell was framed by about a third of the display, all of it about somewhere the user was not.
|
||||
What takes their place is one 52-pixel bar drawn by the surface itself, carrying back on the left and
|
||||
the sessions and a + across from it. See TerminalScreen.axaml.
|
||||
-->
|
||||
|
||||
<Panel>
|
||||
<!--
|
||||
Named, and the name is load-bearing: everything the phone draws is inside this one element, so its
|
||||
bottom margin is the single place the software keyboard can be kept off the box being typed into,
|
||||
whichever of the eleven screens is showing. See PhoneShell.axaml.cs.
|
||||
-->
|
||||
<Panel x:Name="Body">
|
||||
|
||||
<!-- ============ getting in ============ -->
|
||||
<views:PendingScreen IsVisible="{Binding IsStarting}"
|
||||
@@ -45,11 +60,18 @@
|
||||
Hidden behind MORE, and that is the design's arrangement rather than a saving. v2 gives every screen
|
||||
one header carrying that screen's own name and its own actions — a back arrow, an add, a refresh —
|
||||
so the hub's screens draw their own and this one stands down rather than stacking a second row of
|
||||
chrome above theirs. It stays on the three destinations that are the product's top level, where the
|
||||
chrome above theirs. It stays on the two destinations that are the product's top level, where the
|
||||
vault's name and the sync light are the most useful thing a header could say.
|
||||
|
||||
Wrapped rather than given a second condition, because Avalonia's bindings have no "and": the wrapper
|
||||
collapses it over a terminal, where the surface draws its own bar and the vault's name is not what
|
||||
the user is looking at. That is one of three rows this Grid stands down while a shell is showing —
|
||||
see the strip and the bottom bar below.
|
||||
-->
|
||||
<Border Grid.Row="0" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,0,0,1" Padding="14,0" Height="56" IsVisible="{Binding !IsMoreSurface}">
|
||||
<Panel Grid.Row="0" IsVisible="{Binding IsShowingPages}">
|
||||
<Border Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,0,0,1" Padding="14,0" Height="56"
|
||||
IsVisible="{Binding !IsMoreSurface}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
|
||||
<!--
|
||||
@@ -68,9 +90,9 @@
|
||||
Text="{Binding Vault.VaultName}" TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<!--
|
||||
The sync light, and it is green only when it has earned it — see SyncLabel. The design draws a
|
||||
permanently green "Synced" here, which is the one claim on that mock-up this application will
|
||||
not make.
|
||||
The sync light, and it is green only when it has earned it — see SyncLabel. The design draws
|
||||
a permanently green "Synced" here, which is the one claim on that mock-up this application
|
||||
will not make.
|
||||
-->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" Width="6" Height="6"
|
||||
@@ -80,10 +102,12 @@
|
||||
|
||||
<Button Grid.Column="3" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}"
|
||||
ToolTip.Tip="Lock the keychain">
|
||||
<TextBlock Text="LOCK" Classes="label" FontSize="8.5" Foreground="{StaticResource TextDim}" />
|
||||
<TextBlock Text="LOCK" Classes="label" FontSize="8.5"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- The screens. Only one draws; which one is the shell's business. -->
|
||||
<Panel Grid.Row="1">
|
||||
@@ -185,8 +209,14 @@
|
||||
v2 draws the sessions as pills rather than as a labelled row, and drops the word SHELLS: with a
|
||||
rounded chip carrying a live dot and a name, the label was spending nine characters of a 360dp row
|
||||
saying what the row already looks like.
|
||||
|
||||
On every screen except the one it names. The terminal draws these same sessions in its own bar, and
|
||||
two rows of the same pills — one of them 46 pixels of it — is the arrangement this surface exists to
|
||||
stop. Wrapped rather than given a second condition, because the strip's own visibility is about
|
||||
whether there are any tabs and this one is about which surface is up.
|
||||
-->
|
||||
<Border Grid.Row="2" IsVisible="{Binding HasTabs}" Background="{StaticResource Sidebar}"
|
||||
<Panel Grid.Row="2" IsVisible="{Binding IsShowingPages}">
|
||||
<Border IsVisible="{Binding HasTabs}" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Height="46">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding Tabs}" Margin="12,0" VerticalAlignment="Center">
|
||||
@@ -203,9 +233,9 @@
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<!--
|
||||
Green only while there is a shell behind the tab. It used to be lit unconditionally,
|
||||
which was true when a tab could not exist without a session; one can now — connecting
|
||||
opens the tab first — and a dot that was green before anything had answered would be
|
||||
the one thing on this strip claiming something untrue.
|
||||
which was true when a tab could not exist without a session; one can now —
|
||||
connecting opens the tab first — and a dot that was green before anything had
|
||||
answered would be the one thing on this strip claiming something untrue.
|
||||
-->
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
|
||||
VerticalAlignment="Center" />
|
||||
@@ -217,9 +247,19 @@
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- ============ navigation ============ -->
|
||||
<Border Grid.Row="3" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
<!--
|
||||
Gone while a terminal is showing, which is the whole of that surface's arrangement: the bar's four
|
||||
destinations are replaced by a back arrow and a + that leads to three of them, both in the terminal's
|
||||
own bar. See TerminalScreen.axaml.
|
||||
|
||||
This one is bound directly rather than wrapped — its visibility is a single question and it has no
|
||||
second condition of its own to keep separate.
|
||||
-->
|
||||
<Border Grid.Row="3" IsVisible="{Binding IsShowingPages}"
|
||||
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,1,0,0" Height="64">
|
||||
<Grid ColumnDefinitions="*,*,*,*">
|
||||
|
||||
@@ -230,8 +270,13 @@
|
||||
<!--
|
||||
The terminal is a surface rather than a page — see ShellSurface — so this one does not go
|
||||
through ShowScreen. Its own command is on the shell.
|
||||
|
||||
The only entry here that never lights, and deliberately no longer tries: this bar is collapsed
|
||||
while the terminal is showing, so IsCurrent could only ever be read as false. Binding it anyway
|
||||
would be a rule about a state this control cannot be in. What marks the terminal as current is
|
||||
the surface filling the screen.
|
||||
-->
|
||||
<views:NavButton Grid.Column="1" Label="Terminal" Glyph="⌗" IsCurrent="{Binding IsTerminalSurface}"
|
||||
<views:NavButton Grid.Column="1" Label="Terminal" Glyph="⌗"
|
||||
Command="{Binding ShowTerminalCommand}" />
|
||||
|
||||
<!--
|
||||
|
||||
@@ -2,8 +2,10 @@ using global::Android.Views;
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Platform;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
|
||||
using DodoSSH.Client.Android.Platform;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
@@ -15,6 +17,20 @@ internal sealed partial class PhoneShell : UserControl
|
||||
{
|
||||
private MainWindowViewModel? shell;
|
||||
|
||||
/// <summary>
|
||||
/// Everything the phone draws, which is the element the software keyboard is kept off.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Looked up rather than read off the field the name generator declares for <c>x:Name</c>, and
|
||||
/// <c>TerminalScreen</c> does the same for the same reason: that field is assigned by the generated
|
||||
/// <c>InitializeComponent</c>, and no view on this head calls it — they load their XAML directly. Using
|
||||
/// it compiles and is null at run time, which on this control means a crash before the first frame.
|
||||
/// </remarks>
|
||||
private readonly Panel body;
|
||||
|
||||
/// <summary>The software keyboard, while this control is attached. Null on a platform without one.</summary>
|
||||
private IInputPane? keyboard;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the lock screen currently showing is the one the application launched into.
|
||||
/// </summary>
|
||||
@@ -32,6 +48,13 @@ internal sealed partial class PhoneShell : UserControl
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
body = this.FindControl<Panel>("Body")!;
|
||||
|
||||
// Subscribed once, for the life of the control, rather than in OnAttachedToVisualTree: the panel is
|
||||
// this control's own child and cannot outlive it, and re-subscribing on every attach is how a
|
||||
// handler ends up registered twice.
|
||||
body.SizeChanged += OnBodyResized;
|
||||
|
||||
DataContextChanged += (_, _) =>
|
||||
{
|
||||
if (shell is not null)
|
||||
@@ -132,6 +155,14 @@ internal sealed partial class PhoneShell : UserControl
|
||||
if (TopLevel.GetTopLevel(this) is { } top)
|
||||
{
|
||||
top.BackRequested += OnBackRequested;
|
||||
|
||||
keyboard = top.InputPane;
|
||||
|
||||
if (keyboard is not null)
|
||||
{
|
||||
keyboard.StateChanged += OnKeyboardChanged;
|
||||
ApplyKeyboardInset(keyboard);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,9 +174,99 @@ internal sealed partial class PhoneShell : UserControl
|
||||
top.BackRequested -= OnBackRequested;
|
||||
}
|
||||
|
||||
if (keyboard is not null)
|
||||
{
|
||||
keyboard.StateChanged -= OnKeyboardChanged;
|
||||
keyboard = null;
|
||||
}
|
||||
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
}
|
||||
|
||||
private void OnKeyboardChanged(object? sender, InputPaneStateEventArgs e)
|
||||
=> ApplyKeyboardInset(e.NewState is InputPaneState.Open ? e.EndRect.Height : 0);
|
||||
|
||||
private void ApplyKeyboardInset(IInputPane pane)
|
||||
=> ApplyKeyboardInset(pane.State is InputPaneState.Open ? pane.OccludedRect.Height : 0);
|
||||
|
||||
/// <summary>
|
||||
/// Holds the phone's whole interface clear of the software keyboard.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Here rather than on each screen, because the keyboard is not a screen's business.</b> Five of them
|
||||
/// have a box that can be typed into and every one of them would need the same handler; a sixth added
|
||||
/// later would silently not have it. Everything the phone draws is inside <c>Body</c>, so one bottom
|
||||
/// margin shortens all of them at once — which is the same thing the window resizing would have done,
|
||||
/// and is why the two paths below never both apply.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Two paths, one of which is dead on any given device.</b> Before Android 15, the activity's
|
||||
/// <c>AdjustResize</c> makes the platform shorten the window itself and the keyboard inset reaches
|
||||
/// Avalonia already consumed — this measures zero and the margin stays where it is. From Android 15 the
|
||||
/// window is no longer resized for the keyboard at all, edge-to-edge being enforced, and the inset is
|
||||
/// reported instead: that is the number applied here. Adding a margin on top of a window that had
|
||||
/// already shrunk would strand the interface an entire keyboard above the keyboard, which is why the
|
||||
/// value is taken from the inset alone and never from both.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Scrolling the box back into view is deliberately not done here. <c>ScrollViewer</c> already brings a
|
||||
/// newly focused child into view, and every screen with a box on it is inside one; what it cannot know
|
||||
/// is that the visible region shrank *after* the focus. So the trigger is the resize this margin causes
|
||||
/// — see <see cref="OnBodyResized"/> — and not this method, which would run a layout pass too early to
|
||||
/// have anything to scroll to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void ApplyKeyboardInset(double occluded)
|
||||
{
|
||||
var inset = double.IsFinite(occluded) ? Math.Max(occluded, 0) : 0;
|
||||
|
||||
if (Math.Abs(body.Margin.Bottom - inset) > 0.5)
|
||||
{
|
||||
body.Margin = new Thickness(0, 0, 0, inset);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scrolls whatever has the keyboard back into view once the room left for it is known.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one moment this is needed is the one no other handler sees: the box was focused while the whole
|
||||
/// screen was available, and the space it sits in shrank afterwards. Both ways of losing that space end
|
||||
/// here — the margin applied above, and the platform shortening the window on Android 14 and earlier —
|
||||
/// which is why the resize is the trigger rather than either of the two things that cause it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Posted rather than called, and at <c>Loaded</c> priority, because the size change is raised during
|
||||
/// the layout pass that caused it: asking a <c>ScrollViewer</c> to scroll to a child whose new bounds
|
||||
/// have not been written yet scrolls to where the child used to be.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only while the keyboard is up. Every rotation and every screen change resizes this control too, and
|
||||
/// a shell that scrolled to the focused control on each of them would be a shell that moves under you.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnBodyResized(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
if (keyboard is not { State: InputPaneState.Open })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
// Whatever holds focus, not the passphrase box by name: this runs for eleven screens and
|
||||
// the one the keyboard is up for is the only one that can answer which box that is.
|
||||
if (TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() is Control focused)
|
||||
{
|
||||
focused.BringIntoView();
|
||||
}
|
||||
},
|
||||
DispatcherPriority.Loaded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the system back gesture up the hierarchy rather than out of the application.
|
||||
/// </summary>
|
||||
@@ -177,6 +298,13 @@ internal sealed partial class PhoneShell : UserControl
|
||||
/// moving between screens at all. Closing an editor is not the same refusal as leaving a host-key
|
||||
/// decision alone — an editor is abandonable by design, and the CANCEL button beside it says so.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The connect menu is a second such guard, and it matters more than the first.</b> A terminal now
|
||||
/// fills the screen — no header, no bottom bar — so while that menu is up this gesture is the only way
|
||||
/// off it other than the scrim and CANCEL. It is checked before the terminal is dismissed for the
|
||||
/// reason it is drawn over it: back takes the topmost thing, and dismissing the surface underneath a
|
||||
/// menu would take two, neither of them the one being looked at.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnBackRequested(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
@@ -192,6 +320,17 @@ internal sealed partial class PhoneShell : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
// The connect menu, which is raised from the terminal's own bar and is the topmost thing the phone
|
||||
// draws while it is up. Ahead of the editors below because it is nearer, and ahead of leaving the
|
||||
// terminal because a gesture that dismissed the surface underneath a menu would close two things at
|
||||
// once — and the one the user was looking at would not be either of them.
|
||||
if (current.IsConnectSheetOpen)
|
||||
{
|
||||
current.CloseConnectSheetCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryCloseAnOpenEditor(current))
|
||||
{
|
||||
e.Handled = true;
|
||||
|
||||
@@ -16,19 +16,51 @@
|
||||
|
||||
The tab strip is horizontal-scrolling rather than wrapping. Wrapping would reflow the terminal every
|
||||
time a tab opened, which is the one thing a terminal must not do while output is arriving.
|
||||
|
||||
── the screen a shell gets ───────────────────────────────────────────────────────────────────────────
|
||||
A connected phone shows one bar and then the terminal. The vault header, the shells strip and the
|
||||
four-entry bottom bar are all collapsed by PhoneShell while this surface is up, and what replaces them
|
||||
is the row below: back, the sessions, and the way to open another one.
|
||||
|
||||
That is a trade, and the thing bought is the only one a terminal really wants. At 360dp the chrome this
|
||||
screen used to sit inside came to 254 pixels of a roughly 780-pixel display — a third of it — and every
|
||||
one of those rows was about somewhere the user was not. What is given up is the bottom bar's one-tap
|
||||
reach to Hosts, Keychain and MORE; back and the + between them lead to all of it, and the system back
|
||||
gesture does the same thing the arrow does.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto">
|
||||
<Panel>
|
||||
|
||||
<!-- ============ tabs ============ -->
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<!-- ============ the bar ============ -->
|
||||
<!--
|
||||
v2 draws these as pills rather than as a segmented strip, so the row is transparent and each session
|
||||
carries its own outline. The close cross moved inside the pill with the name, which is what makes it
|
||||
read as one object you can dismiss rather than as two adjacent targets.
|
||||
Everything the phone draws above a shell. It is a bar rather than a strip because it now carries the
|
||||
two controls the collapsed chrome took with it, one at each end, with the sessions between them.
|
||||
|
||||
Both are outside the ScrollViewer deliberately. They are the way out of this surface and the way to
|
||||
another host, and a tenth tab must not be able to push either of them off the right-hand edge.
|
||||
-->
|
||||
<Border Grid.Row="0" Height="52">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding Tabs}" Margin="12,0" VerticalAlignment="Center">
|
||||
<Border Grid.Row="0" Height="52" Background="{StaticResource Chrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
|
||||
<!--
|
||||
Back, and it goes to the page this terminal was opened over rather than to Hosts by name. The
|
||||
system back gesture already does exactly that — see PhoneShell.axaml.cs — and an arrow that
|
||||
landed somewhere else would be the second of two answers to one question.
|
||||
-->
|
||||
<Button Grid.Column="0" Classes="icon" Content="←" Margin="4,0,0,0"
|
||||
Command="{Binding ShowScreenCommand}" CommandParameter="{Binding Screen}" />
|
||||
|
||||
<!--
|
||||
v2 draws these as pills rather than as a segmented strip, so the row is transparent and each
|
||||
session carries its own outline. The close cross moved inside the pill with the name, which is
|
||||
what makes it read as one object you can dismiss rather than as two adjacent targets.
|
||||
-->
|
||||
<ScrollViewer Grid.Column="1" HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding Tabs}" Margin="6,0" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
@@ -77,52 +109,26 @@
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<!-- ============ the connection line ============ -->
|
||||
<!--
|
||||
The text-size buttons live here rather than in the accessory row below, and the row is the reason:
|
||||
that one scrolls, so a key can be off-screen, and these two must not be — a terminal that is too
|
||||
small to read is exactly the state in which hunting for the control that fixes it is worst.
|
||||
|
||||
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head has
|
||||
no keyboard to press it with, which is why the two heads differ here and nowhere else in this screen.
|
||||
-->
|
||||
<!--
|
||||
The design's line here also carries a round-trip time and a forwarded port. Neither is drawn: the SSH
|
||||
library offers no RTT measurement, and nothing in this application forwards anything. What is left is
|
||||
the one fact that is real and is the one that matters — the account and endpoint actually dialled.
|
||||
-->
|
||||
<Border Grid.Row="1" Padding="16,5" Height="36"
|
||||
IsVisible="{Binding SelectedTab, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="detail" FontSize="10.5" TextTrimming="CharacterEllipsis"
|
||||
Foreground="{StaticResource TextDim}"
|
||||
VerticalAlignment="Center" Text="{Binding SelectedTab.Address}" />
|
||||
|
||||
<!--
|
||||
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
|
||||
nothing reads as the terminal having stopped responding, which is the one thing this screen must
|
||||
never look like.
|
||||
-->
|
||||
<Button Grid.Column="1" Classes="row" MinHeight="34" MinWidth="38" Padding="0"
|
||||
HorizontalContentAlignment="Center"
|
||||
Command="{Binding ShrinkTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanShrinkTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
||||
</Button>
|
||||
Another connection, and the three kinds this application can make. A menu rather than a straight
|
||||
jump to Hosts because SFTP and S3 used to be two taps through the bottom bar's MORE, and the bar
|
||||
is not on screen here — so the control that replaces it has to lead to all three or it has quietly
|
||||
removed two of them.
|
||||
|
||||
<Button Grid.Column="2" Classes="row" MinHeight="34" MinWidth="38" Padding="0" Margin="4,0,0,0"
|
||||
HorizontalContentAlignment="Center"
|
||||
Command="{Binding EnlargeTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
||||
</Button>
|
||||
The desktop's own + refuses a flyout on this reasoning, in TerminalTabs.axaml: a popup dropping
|
||||
into the renderer's rectangle may or may not composite above a native child window, and that is
|
||||
not a claim to make without a screenshot. It is answered here rather than dodged — this is a sheet
|
||||
at the bottom of the screen and opening it collapses the renderer outright, exactly as the
|
||||
palette does on the desktop. Nothing is drawn over the WebView.
|
||||
-->
|
||||
<Button Grid.Column="2" Classes="icon accent" Content="+" Margin="0,0,4,0"
|
||||
Command="{Binding OpenConnectSheetCommand}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ the renderer ============ -->
|
||||
<Panel Grid.Row="2">
|
||||
<Panel Grid.Row="1">
|
||||
|
||||
<!--
|
||||
The empty state, and it says what the surface is for rather than that it is empty. A phone opens
|
||||
@@ -131,7 +137,7 @@
|
||||
<StackPanel IsVisible="{Binding !HasTabs}" VerticalAlignment="Center" Margin="24" Spacing="10">
|
||||
<TextBlock Classes="title" FontSize="13" Text="NO SHELL OPEN" />
|
||||
<TextBlock Classes="body"
|
||||
Text="Choose a host and press CONNECT. A shell opened here keeps running while the app is in the background, and keeps running after the keychain is locked — a notification says so for as long as one is alive." />
|
||||
Text="Press + above, or choose a host and press CONNECT. A shell opened here keeps running while the app is in the background, and keeps running after the keychain is locked — a notification says so for as long as one is alive." />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
@@ -140,10 +146,17 @@
|
||||
hold the application still any more, so there is a stretch in which a tab is selected and there is
|
||||
nothing yet to render in it. A phone needs it more than a desktop does: mobile links are slower, and
|
||||
the alternative is a black rectangle.
|
||||
|
||||
It carries the address, which is where that fact went when the connection line was folded into the
|
||||
bar above. This is the moment it is worth reading — what is being dialled, before anything has
|
||||
answered — and once a shell is open its own prompt says the same thing more accurately than a header
|
||||
derived from the keychain ever did.
|
||||
-->
|
||||
<StackPanel IsVisible="{Binding IsConnectingShowing}" VerticalAlignment="Center" Margin="24"
|
||||
Spacing="10">
|
||||
<TextBlock Classes="title" FontSize="13" Text="{Binding SelectedTab.Label}" />
|
||||
<TextBlock Classes="detail" FontSize="11" Foreground="{StaticResource TextDim}"
|
||||
TextWrapping="Wrap" Text="{Binding SelectedTab.Address}" />
|
||||
<TextBlock Classes="body" Text="{Binding SelectedTab.Status}" />
|
||||
<Button Classes="row" MinHeight="44" Padding="14,0" HorizontalAlignment="Left"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
|
||||
@@ -153,13 +166,14 @@
|
||||
|
||||
<!--
|
||||
Collapsed rather than merely covered when there is no pane to show. On Windows this control is a
|
||||
native child window that composites above everything Avalonia draws, which is why the desktop head
|
||||
native child view that composites above everything Avalonia draws, which is why the desktop head
|
||||
hides it explicitly; whether Android's WebView does the same is recorded as unverified in
|
||||
docs/android-port.md. Hiding it either way costs nothing and is correct under both answers.
|
||||
|
||||
IsTerminalShowing rather than HasTabs, which are no longer the same question: a tab that is still
|
||||
connecting has no pane, and showing the renderer for it would show the previous session's output
|
||||
under the name of a machine nothing has connected to yet.
|
||||
under the name of a machine nothing has connected to yet. It is also what the connect sheet turns
|
||||
off — see MainWindowViewModel.IsTerminalShowing.
|
||||
|
||||
v2 insets this behind a 14-pixel radius. Not done, and not an oversight: this is a native child view
|
||||
composited above everything Avalonia draws, so a rounded Border behind it clips nothing — the
|
||||
@@ -178,13 +192,135 @@
|
||||
then release, because holding a modifier while typing is not possible one-thumbed.
|
||||
-->
|
||||
<!-- Only with a pane to type into: the keys send bytes at a session, and a connecting tab has none. -->
|
||||
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Height="50">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<Border Grid.Row="2" IsVisible="{Binding IsTerminalShowing}" Height="50">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
|
||||
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Disabled">
|
||||
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Spacing="5" Margin="12,0"
|
||||
VerticalAlignment="Center" />
|
||||
</ScrollViewer>
|
||||
|
||||
<!--
|
||||
The text-size buttons, pinned at this row's right-hand end rather than scrolling with the keys
|
||||
beside them.
|
||||
|
||||
They used to have a row of their own above the terminal, on the argument that the accessory row
|
||||
scrolls and these two must never be off-screen — a terminal too small to read is exactly the state
|
||||
in which hunting for the control that fixes it is worst. That argument is answered rather than
|
||||
abandoned: outside the ScrollViewer they cannot scroll away, and the row they had costs 36 pixels
|
||||
on a surface this change exists to give back.
|
||||
|
||||
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
|
||||
has no keyboard to press it with, which is why the two heads differ here and nowhere else in this
|
||||
screen.
|
||||
|
||||
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
|
||||
nothing reads as the terminal having stopped responding, which is the one thing this screen must
|
||||
never look like.
|
||||
-->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="5" Margin="8,0,12,0"
|
||||
VerticalAlignment="Center">
|
||||
<Border Width="1" Height="26" Background="{StaticResource Border}" Margin="0,0,3,0"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<Button Classes="row" MinHeight="38" Height="38" MinWidth="40" Padding="0" CornerRadius="9"
|
||||
HorizontalContentAlignment="Center"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1"
|
||||
Command="{Binding ShrinkTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanShrinkTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" MinHeight="38" Height="38" MinWidth="40" Padding="0" CornerRadius="9"
|
||||
HorizontalContentAlignment="Center"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1"
|
||||
Command="{Binding EnlargeTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ============ the connect sheet ============ -->
|
||||
<!--
|
||||
Follows the add sheet on HostsScreen, which follows HostKeySheet: a scrim, a bottom-aligned panel with
|
||||
the top two corners rounded, and a grab handle that is decoration. Dismissible, like the add sheet and
|
||||
unlike the host-key one — "which kind of connection" has no wrong answer and no answer at all is one
|
||||
of them.
|
||||
|
||||
It lives here rather than in PhoneShell for the reason the add sheet lives in its own screen: nothing
|
||||
but this surface raises it. The scrim reaching only the screen area is not a compromise here the way
|
||||
it was there — the bottom bar is collapsed while a terminal is showing, so the screen area is the
|
||||
display.
|
||||
|
||||
Every row navigates away from the terminal. That is not a side effect of the menu, it is the menu:
|
||||
each of the three destinations is a picker, and the shell they open lands back on this surface as a
|
||||
new tab in the bar above.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsConnectSheetOpen}">
|
||||
|
||||
<!--
|
||||
Declared before the sheet so the sheet draws over it: a Panel stacks its children in declaration
|
||||
order. See the .scrim style for why a tap on it must not light anything up.
|
||||
-->
|
||||
<Button Classes="scrim" Command="{Binding CloseConnectSheetCommand}" />
|
||||
|
||||
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
|
||||
CornerRadius="22,22,0,0" Padding="20,18,20,16">
|
||||
<StackPanel Spacing="0">
|
||||
|
||||
<Border Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,16" />
|
||||
|
||||
<TextBlock Classes="title" Text="CONNECT" FontSize="13" />
|
||||
|
||||
<Button Classes="row" Margin="0,10,0,0" Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Hosts}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Connect" />
|
||||
<TextBlock Classes="detail" FontSize="10.5"
|
||||
Text="Another shell, on this host or any other in the keychain." />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
These two go through ShowFiles rather than ShowScreen, as the MORE hub's own rows do: one screen
|
||||
over one view model, and which kind of remote it offers is the thing being chosen. It can refuse
|
||||
— there is a single transfer session behind both — and refusing lands on the screen the open one
|
||||
belongs to with a sentence saying why, which is a better place to read it than a sheet that has
|
||||
just closed.
|
||||
-->
|
||||
<Button Classes="row" Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Host}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Connect via SFTP" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="Browse a host's files." />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Bucket}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Connect via S3" />
|
||||
<TextBlock Classes="detail" FontSize="10.5"
|
||||
Text="Objects in an S3-compatible bucket from the keychain." />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="secondary" Content="CANCEL" Margin="0,12,0,0"
|
||||
Command="{Binding CloseConnectSheetCommand}" />
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
</Panel>
|
||||
|
||||
</UserControl>
|
||||
|
||||
@@ -418,25 +418,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two defaults, because the two audiences never overlap. A release build is installed by somebody
|
||||
/// signing in to the hosted deployment, and typing its address is the only thing standing between
|
||||
/// them and a working application. A debug build is run from a clone, next to
|
||||
/// <c>dotnet run --project src/DodoSSH.Api</c>, and shipping the hosted address there would point
|
||||
/// every development launch at production — which is worse than an inconvenience, since sign-in is
|
||||
/// the step that enrolls a device.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Note the schemes. <c>http</c> locally is not an oversight: the API's first launch profile — the
|
||||
/// one a plain <c>dotnet run</c> and the README both select — is plaintext on 5233, and pointing an
|
||||
/// HTTPS client at a plaintext port fails as "The SSL connection could not be established", which
|
||||
/// One default for every build. The hosted deployment is what all but a handful of launches are
|
||||
/// aiming at, and typing its address is the only thing standing between an installed application and
|
||||
/// a working one. Running against a clone means replacing this with
|
||||
/// <c>http://localhost:5233</c> by hand — note the scheme, because the API's first launch profile —
|
||||
/// the one a plain <c>dotnet run</c> and the README both select — is plaintext on 5233, and pointing
|
||||
/// an HTTPS client at a plaintext port fails as "The SSL connection could not be established", which
|
||||
/// sends people looking for a certificate problem. See <see cref="ExplainSignInFailure" />.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This was once split on <c>DEBUG</c> so a development launch could not enroll a device against
|
||||
/// production by accident. That protection is gone: a debug build now offers the hosted address like
|
||||
/// any other, and the first sign-in accepted unread lands there.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
#if DEBUG
|
||||
internal const string DefaultServerUrl = "http://localhost:5233";
|
||||
#else
|
||||
internal const string DefaultServerUrl = "https://ssh.dodotech.cloud";
|
||||
#endif
|
||||
|
||||
[ObservableProperty]
|
||||
private string serverUrl = DefaultServerUrl;
|
||||
@@ -803,8 +799,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// and a child window composites above everything its parent paints — so whatever Avalonia draws in the
|
||||
/// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the
|
||||
/// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a
|
||||
/// locked vault (the unlock card), the page area (every screen uses the full width), and the
|
||||
/// quick-connect palette.
|
||||
/// locked vault (the unlock card), the page area (every screen uses the full width), the quick-connect
|
||||
/// palette, and the phone's connect sheet.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The sheet is here rather than in <see cref="IsTerminalSurface"/>, and the palette is not.</b> The
|
||||
/// palette replaces the whole surface, so collapsing everything the terminal half draws is right. The
|
||||
/// sheet is raised from the terminal's own top bar and that bar has to stay on screen behind it —
|
||||
/// dropping the surface would take the bar, the tabs and the phone's whole chrome with it and leave the
|
||||
/// sheet floating over the page underneath. So only the renderer's rectangle is given up.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The terminal and the pages are exclusive, and that is the whole of the rule.</b> They share one
|
||||
@@ -833,7 +836,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// safe — that detaches it and destroys the whole WebView2 process tree.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsTerminalShowing => IsTerminalSurface && SelectedTab is { HasSession: true };
|
||||
internal bool IsTerminalShowing =>
|
||||
IsTerminalSurface && !IsConnectSheetOpen && SelectedTab is { HasSession: true };
|
||||
|
||||
/// <summary>
|
||||
/// Whether the terminal half of the window is the half being shown, pane or no pane.
|
||||
@@ -897,6 +901,52 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[RelayCommand]
|
||||
private void ShowTerminal() => Surface = ShellSurface.Terminal;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the phone's connect menu is open over the terminal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Drawn by the Android head alone, and shell state rather than something that view could hold on its
|
||||
/// own for the reason <see cref="IsSearching"/> is: it has to collapse the renderer while it is up. See
|
||||
/// <see cref="IsTerminalShowing"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It exists because the phone gives a terminal the whole screen. The bottom bar and the vault header
|
||||
/// are gone while a shell is showing, so the three things that bar was the way to — a host, a host's
|
||||
/// files, a bucket — need a way back that is not "leave the terminal first and remember what you were
|
||||
/// doing". The menu is that, and every entry on it is one of the two navigation commands above.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isConnectSheetOpen;
|
||||
|
||||
/// <summary>Raises the connect menu over the terminal.</summary>
|
||||
/// <remarks>
|
||||
/// Gated on the terminal surface rather than merely trusting its only button to be off screen otherwise.
|
||||
/// The flag collapses the renderer, so one set while a page was showing would be a sheet nobody can see
|
||||
/// holding a terminal hidden that nothing would put back.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void OpenConnectSheet()
|
||||
{
|
||||
if (!IsTerminalSurface)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsConnectSheetOpen = true;
|
||||
}
|
||||
|
||||
/// <summary>Lowers the connect menu, leaving the terminal where it was.</summary>
|
||||
/// <remarks>
|
||||
/// The scrim, the CANCEL row and the system back gesture all come here. Choosing an entry does not, and
|
||||
/// does not need to: every entry navigates, and leaving the terminal surface lowers the sheet on its own
|
||||
/// — see <see cref="OnSurfaceChanged"/>, which is what makes "the sheet is only ever up over a terminal"
|
||||
/// true of routes nobody wrote it for.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void CloseConnectSheet() => IsConnectSheetOpen = false;
|
||||
|
||||
// ---- Open terminals ----
|
||||
|
||||
/// <summary>
|
||||
@@ -2541,7 +2591,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="OnScreenChanged" />
|
||||
partial void OnSurfaceChanged(ShellSurface value) => RaiseSurfaceState();
|
||||
/// <remarks>
|
||||
/// <b>The one place the connect sheet is lowered by something other than a tap.</b> Every way out of a
|
||||
/// terminal ends here — a rail or bottom-bar destination, the files screen, the palette connecting to a
|
||||
/// host, closing the last tab, a lock — and each of them would otherwise leave the flag set on a shell
|
||||
/// showing a page. That is not merely untidy: the flag collapses the renderer, so the next return to the
|
||||
/// terminal would draw the sheet again over a rectangle held blank by it.
|
||||
/// </remarks>
|
||||
partial void OnSurfaceChanged(ShellSurface value)
|
||||
{
|
||||
if (value is not ShellSurface.Terminal)
|
||||
{
|
||||
IsConnectSheetOpen = false;
|
||||
}
|
||||
|
||||
RaiseSurfaceState();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Both changes raise the same set, and they have to: <see cref="IsHostsShowing"/> and its four siblings
|
||||
@@ -2604,6 +2669,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
partial void OnIsSearchingChanged(bool value) => RaiseTerminalState();
|
||||
|
||||
/// <inheritdoc cref="OnIsSearchingChanged" />
|
||||
partial void OnIsConnectSheetOpenChanged(bool value) => RaiseTerminalState();
|
||||
|
||||
/// <remarks>
|
||||
/// The unlock card and the confirmation swap, so arming one has to hide the other — see
|
||||
/// <see cref="IsAskingForThePassphrase"/>.
|
||||
|
||||
@@ -303,20 +303,15 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
/// application is not running.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Both branches are written out rather than compared against the constant itself. Asserting a
|
||||
/// constant against itself would pass however it were edited, and the whole point of this test is
|
||||
/// that a release build must not ship a developer's loopback address — or, since the split, that a
|
||||
/// debug build must not point a clone at production.
|
||||
/// The address is written out rather than compared against the constant itself. Asserting a constant
|
||||
/// against itself would pass however it were edited, and the whole point of this test is that no
|
||||
/// build ships a developer's loopback address.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void TheDefaultServerUrl_IsTheHostedDeployment_ExceptInADebugBuild()
|
||||
public void TheDefaultServerUrl_IsTheHostedDeployment_InEveryBuild()
|
||||
{
|
||||
#if DEBUG
|
||||
shell.ServerUrl.ShouldBe("http://localhost:5233");
|
||||
#else
|
||||
shell.ServerUrl.ShouldBe("https://ssh.dodotech.cloud");
|
||||
#endif
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -687,6 +682,82 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
shell.Screen.ShouldBe(ShellScreen.Vault);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The phone's connect menu is drawn over the terminal's own rectangle, so it obeys the rule the palette
|
||||
/// does: whatever covers the renderer collapses it instead. The surface stays, because the bar the menu
|
||||
/// was raised from is part of it — see <c>MainWindowViewModel.IsTerminalShowing</c>.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheConnectSheet_HidesTheRendererAndLeavesTheSurfaceUnderIt()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.OpenConnectSheetCommand.Execute(null);
|
||||
|
||||
shell.IsConnectSheetOpen.ShouldBeTrue();
|
||||
shell.IsTerminalShowing.ShouldBeFalse("the sheet draws over the renderer's rectangle");
|
||||
shell.IsTerminalSurface.ShouldBeTrue("the bar the sheet was raised from is on that surface");
|
||||
|
||||
shell.CloseConnectSheetCommand.Execute(null);
|
||||
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The flag holds the renderer blank, so one set while a page was showing would be a sheet nobody can
|
||||
/// see keeping a terminal hidden that nothing would put back.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheConnectSheet_RefusesToOpenOverAPage()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Vault);
|
||||
|
||||
shell.OpenConnectSheetCommand.Execute(null);
|
||||
|
||||
shell.IsConnectSheetOpen.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Every entry on the menu navigates, and none of them closes the sheet itself: leaving the terminal
|
||||
/// surface is what lowers it. That is the guarantee worth a test — it is what makes routes nobody wrote
|
||||
/// the sheet for, like closing the last tab or locking, safe.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task LeavingTheTerminal_LowersTheConnectSheetHoweverItIsLeft()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
// The menu's own second entry: one screen, over one view model, with the kind of remote chosen by
|
||||
// the thing that navigates.
|
||||
shell.OpenConnectSheetCommand.Execute(null);
|
||||
shell.ShowFilesCommand.Execute(RemoteKind.Bucket);
|
||||
|
||||
shell.IsConnectSheetOpen.ShouldBeFalse();
|
||||
shell.IsBucketsShowing.ShouldBeTrue();
|
||||
|
||||
// And a route the sheet was never wired to: back to the terminal, open it, then end the only shell
|
||||
// there is.
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.OpenConnectSheetCommand.Execute(null);
|
||||
shell.IsConnectSheetOpen.ShouldBeTrue();
|
||||
|
||||
await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]);
|
||||
|
||||
shell.IsConnectSheetOpen.ShouldBeFalse("closing the last tab returns the surface to a page");
|
||||
shell.IsShowingPages.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A visible WebView with no pane in it reads as the application having broken, so this is the one
|
||||
/// transition that moves the surface back on its own.
|
||||
|
||||
Reference in New Issue
Block a user