Public Access
Packaging for Windows, and the updater that only exists once something is
packaged. Velopack, win-x64, fed from the project's own forge — never from the
deployment a client signs in to, which is ADR 0011 rule 2 carried over
unchanged and is why the feed address is a constant in the code rather than a
setting. See docs/adr/0012-desktop-distribution-and-updates.md.
**Nothing is ever installed while somebody is using it.** A newer build is found
on a six-hourly pass, downloaded in the background, and then waits — for a
restart the user presses, or for the next launch they were going to do anyway.
That is a policy rather than caution: this application argues at length that
locking keeps shells running, because a lock that destroyed work would stop
being used, and a restart does not keep them. Having taught that, it owes the
user the choice at the one moment it stops being true, and the sentence saying
so counts the shells it would close.
**The version is now derived from the v* tag**, by MinVer, for everything. There
was no version before this — no property anywhere, so every assembly reported
the SDK's 1.0.0 and the API served that string as its serverVersion to every
client that asked. The tag was already the version of record for the container
image; this makes it the version of record full stop. MinVer's failure mode is
answering plausibly rather than failing, and here a wrong version is a client
that never updates, so it is guarded twice: fetch-depth 0 on every checkout, and
a step that fails a tag build when the tag and the computed version disagree.
**The pack id is DodoSSH.Desktop and not DodoSSH**, which is the one decision
here that would have destroyed data. Velopack installs to %LOCALAPPDATA%\<packId>
and removes that whole directory on uninstall, and %LOCALAPPDATA%\DodoSSH is
where ClientPaths keeps the encrypted cache, the outbox of changes not yet
pushed, and the device key. The obvious id would have had the uninstaller
silently delete work the server has never seen — the thing the application
refuses to do without a counted confirmation. Velopack's own advice to move user
data to roaming %APPDATA% is declined for the reason ClientPaths already gives.
**Releases are cut by a person, and CI gains no job that could.** The tempting
argument is that a forge write token is not a signing key. It does not survive
contact with what the token does: Velopack clients trust their feed and do not
verify a package signature when they apply one, so whoever can write a release
can ship an update every install runs. That is the capability ADR 0011 rule 1
puts on a machine which is not a runner, reached through a different door. The
mechanical objection — vpk needs Windows and the runners are Linux — is the
smaller of the two and is recorded beside it, because somebody will fix one and
believe they are done.
Unsigned for now, deliberately and with the cost stated where a user reads it:
SmartScreen warns once per person, on Setup.exe, because Mark-of-the-Web is
applied by the browser that downloaded it. In-app updates are fetched by the
application and applied from a local file, and never trip it.
The banner is a fourth row of the window rather than an overlay. Anything drawn
in the terminal's rectangle is sliced by the native child window that composites
above it — the defect this window has shipped once — and a sibling row is the
arrangement TitleBar and StatusBar already prove works.
----
Three defects surfaced on the way, none of them in the feature being built.
**A settings key absent from the file came back as the CLR default, not the
declared one.** The JSON source generator builds a record through a synthesised
parameterised constructor and assigns every property from its argument array, so
a property initializer runs and is then overwritten by a default for anything the
file did not contain. A settings.json of {} read back a font size of 0, clamped
up to the 8px floor rather than the 13px the renderer draws at. It could not bite
while there was one setting, because that setting was written on every save and
so was never absent; adding a second would have turned automatic update checks
off for every existing profile, silently, the opposite of the documented default.
Reflection-based deserialisation of the same JSON answers correctly, which is why
every way of checking it by hand agrees except the one that ships. The defaults
now live on the constructor parameters, which is the only place the generator
reads them from.
**Declaring a RuntimeIdentifier on the desktop head broke the server's image
build.** It is the obvious way to let a self-contained publish restore under
locked mode, and it writes a net10.0/win-x64 target into the lock file of every
project the head references transitively — including DodoSSH.Contracts and
DodoSSH.Crypto, which the API builds too. The Dockerfile restores those with no
RID and fails NU1004. Found by running docker build rather than by reading. The
RID stays out of the committed state; the two commands that need one ask for it
unlocked, and the release script puts the lock files back.
**A Docker ARG named VERSION silently sets MSBuild's Version.** An ARG is an
environment variable for the rest of the stage, MSBuild reads environment
variables as properties, and property names are case-insensitive. With the
workflow passing main-<short sha> on a main build the publish died with
NETSDK1018 pointing at DodoSSH.Contracts, a project nobody had touched. The build
stage's argument is ASSEMBLY_VERSION now, empty except on a tag build.
All three are in docs/platform-flags.md, which is where the next person will look.
----
Verified: the whole solution builds and restores locked; 289 shell, 93 layout and
54 session tests pass, including the regression test for the settings defect and
a measurement of the banner at the window's minimum width. vpk pack runs end to
end and reports "Verified VelopackApp.Run()" against Program.Main. The API image
builds correctly both as a main build and as a tag build, carrying 1.0.0 and
0.1.0 respectively.
Not verified, and it needs a published release to be: installing, updating and
uninstalling on a real machine. That is Phase 15 of docs/manual-checks.md, and
the pack id and the WebView2 profile fix are reasoned and commented but only
proved by walking it. Two things to watch at the first upload — the reverse
proxy's body-size limit for a 64 MB asset, and whether vpk upload gitea is happy
with Gitea 1.27.1.
359 lines
20 KiB
XML
359 lines
20 KiB
XML
<Window xmlns="https://github.com/avaloniaui"
|
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
xmlns:sys="using:System.Collections.Generic"
|
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
|
xmlns:views="using:DodoSSH.Client.App.Views"
|
|
x:Class="DodoSSH.Client.App.Views.MainWindow"
|
|
x:DataType="vm:MainWindowViewModel"
|
|
Title="DodoSSH"
|
|
Icon="/Assets/dodossh.ico"
|
|
Width="1180"
|
|
Height="760"
|
|
MinWidth="1016"
|
|
MinHeight="574"
|
|
Background="{StaticResource Canvas}"
|
|
SystemDecorations="BorderOnly"
|
|
Focusable="True">
|
|
|
|
<!--
|
|
The shell window: a titlebar it draws itself, a sidebar, a tab strip, one surface at a time, and a
|
|
status bar.
|
|
|
|
THE MINIMUM GREW, and it grew by exactly what v2 added rather than by a round number somebody liked.
|
|
The sidebar went from 54 pixels to 190 and the chrome from 72 tall to 86, so 880x560 became 1016x574 —
|
|
which leaves every screen the same 826x464 it was designed against. That is the whole point of moving
|
|
it: the layout suite's premise is that everything fits at the minimum, and four of the tables stop
|
|
fitting at 690 wide. Widening the sidebar without widening the window would have quietly broken them
|
|
somewhere nobody was looking.
|
|
|
|
Windows is asked for a resize border and nothing else, so TitleBar does the dragging, the maximising and
|
|
the closing. That is a real cost, and the reason it is paid is that a stock grey system bar above a
|
|
near-black application is the one part of the window that would look borrowed.
|
|
|
|
SystemDecorations="BorderOnly" rather than extending the client area under the system chrome, which is
|
|
what this tried first and is measurably wrong on Avalonia 12: ExtendClientAreaChromeHints is gone from
|
|
that version, so extending leaves the caption buttons and the window title drawn on top of this
|
|
window's own — two sets of minimise/maximise/close, and "DodoSSH" printed twice, overlapping. BorderOnly
|
|
removes the caption and keeps the resize border and the drop shadow, which is the half of the system
|
|
chrome worth having.
|
|
|
|
TWO SURFACES, ONE RECTANGLE.
|
|
|
|
The tab strip is above everything the nav rail leads to, so a terminal opened from any screen stays
|
|
visible and reachable from every other one. What that costs is that the terminal and the pages now share
|
|
the area beneath the strip, and exactly one of them may occupy it. That is the whole of ShellSurface: an
|
|
enum rather than two flags, so there is no way to write the state where both are showing.
|
|
|
|
THE OCCLUSION RULE, which every arrangement in this file obeys.
|
|
|
|
NativeWebView hosts a real Win32 child window through NativeControlHost, and a child window composites
|
|
above everything the parent paints — so no sibling in this visual tree can cover it, whatever the
|
|
z-order says. Layering a screen over it leaves that screen sliced at the WebView's left edge with its
|
|
buttons unreachable, which this window has shipped once already.
|
|
|
|
So anything that would occupy the terminal's rectangle collapses the terminal instead, and
|
|
IsTerminalShowing is the one place that decision is made: a locked vault, the page area, or the
|
|
quick-connect palette. Collapsing is safe, and cheaply so — NativeControlHost creates the native
|
|
control when the control is attached to the visual tree, not when it is laid out or shown, so WebView2
|
|
still starts, still loads the page and still lets the renderer attach its socket while it is false. It
|
|
only swaps ShowInBounds for HideWithSize, and flipping it back re-pushes the bounds.
|
|
|
|
Note where IsShowingPages is bound: on the one Panel that holds every screen, not on each screen. That
|
|
is what makes the rule hard to break rather than merely documented — a sixth screen added inside that
|
|
Panel cannot forget to collapse, because it is not the thing doing the collapsing. Its own IsVisible
|
|
only chooses between the pages.
|
|
|
|
Two nearby alternatives are wrong. Removing the control from the tree instead — conditional content, a
|
|
template swap — detaches it, and detaching destroys the native control and the whole WebView2 process
|
|
tree, so every unlock would pay a cold start. Hoisting the binding to an ancestor looks tidier and is
|
|
unverified: NativeControlHost does watch ancestors, but NativeWebView's own bounds-and-scaling re-push
|
|
fires only for its own IsVisible.
|
|
-->
|
|
|
|
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
|
|
|
<views:TitleBar Grid.Row="0" />
|
|
|
|
<Panel Grid.Row="1">
|
|
|
|
<!-- The unlocked application. -->
|
|
<Grid RowDefinitions="Auto,*" IsVisible="{Binding IsUnlocked}">
|
|
|
|
<!--
|
|
◆ THE STRIP IS ABOVE THE RAIL, and it used to be beside it.
|
|
|
|
It was the other way round for a reason that stopped being true: while every tab was a terminal,
|
|
the strip navigated only the area to the right of a full-height rail, and putting it over the rail
|
|
would have been a row of tabs above a column of destinations they had nothing to do with.
|
|
|
|
The three fixed tabs are what changed that. The rail is now one tab's contents rather than the
|
|
window's own furniture — Vaults owns it, SFTP and S3 do not have it, and a terminal does not
|
|
either — so a rail drawn beside the strip would outrank the thing that decides whether it is
|
|
showing at all. Above and full width is the arrangement that matches what selects what.
|
|
-->
|
|
<views:TerminalTabs Grid.Row="0" />
|
|
|
|
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
|
|
|
|
<!--
|
|
The Vaults tab's own navigation, and it collapses with that tab. Its width is 190 either way, so
|
|
SFTP, S3 and a terminal each get the full window rather than the 826 a page gets.
|
|
-->
|
|
<views:NavRail Grid.Column="0" IsVisible="{Binding IsVaultsTab}" />
|
|
|
|
<Panel Grid.Column="1">
|
|
|
|
<!-- ============ THE PAGES ============ -->
|
|
<Panel IsVisible="{Binding IsShowingPages}">
|
|
|
|
<!--
|
|
Wrapped, like every other screen here. It used to be bound directly — its data context was
|
|
the shell's, so IsHostsScreen resolved on the same element — and that was only so it could
|
|
hand the vault to the host sidebar from inside its own markup. The sidebar is gone and the
|
|
drawer that replaced it is a plain child, so the screen takes the vault like the rest and
|
|
its visibility goes on the wrapper.
|
|
-->
|
|
<Panel IsVisible="{Binding IsHostsScreen}">
|
|
<views:HostsScreen x:Name="HostsPane" DataContext="{Binding Vault}" />
|
|
</Panel>
|
|
|
|
<!-- ============ SFTP ============ -->
|
|
<!--
|
|
Inside this Panel although it is a tab rather than a rail screen, and that is not an
|
|
oversight. IsShowingPages means "the Avalonia page area, not the WebView", which is the
|
|
occlusion question and is true of all three fixed tabs; which of them is showing is the
|
|
separate question each child below answers. Keeping the two apart is what lets the terminal
|
|
stay collapsed under one rule rather than under four.
|
|
|
|
What differs from a rail screen is only the rail: NavRail collapses on IsVaultsTab above, so
|
|
this screen is laid out at the full window width.
|
|
|
|
Wrapped rather than bound directly, for the same reason the vault screen is: this element's
|
|
visibility is the shell's business and its data context is the transfers view model, and
|
|
putting both on one element resolves IsVisible against that view model, where
|
|
IsTransfersScreen does not exist.
|
|
-->
|
|
<Panel IsVisible="{Binding IsTransfersScreen}">
|
|
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
|
</Panel>
|
|
|
|
<!-- ============ S3 ============ -->
|
|
<!--
|
|
The same screen as SFTP above, over the same view model, because an object store and an
|
|
SFTP host are both an IRemoteFileStore and everything below the picker was written once.
|
|
What differs is which picker is offered, and that is decided by the destination rather than
|
|
by a toggle inside the screen — see ShowFiles, and the tab in the strip that calls it.
|
|
-->
|
|
<Panel IsVisible="{Binding IsBucketsScreen}">
|
|
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
|
</Panel>
|
|
|
|
<!-- ============ KEYCHAIN ============ -->
|
|
<!--
|
|
Wrapped rather than bound directly, for the reason the vault column always was: this
|
|
element's visibility is the shell's business and its data context is the vault, and put both
|
|
on one element and IsVisible resolves against the vault as well, where IsKeychainScreen does
|
|
not exist.
|
|
-->
|
|
<Panel IsVisible="{Binding IsKeychainScreen}">
|
|
<views:KeychainScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
|
|
</Panel>
|
|
|
|
<!-- ============ HOST KEYS ============ -->
|
|
<!--
|
|
Wrapped, like the two above and for the same reason: its data context is the screen's own
|
|
view model, where IsKnownHostsScreen does not exist.
|
|
-->
|
|
<Panel IsVisible="{Binding IsKnownHostsScreen}">
|
|
<views:KnownHostsScreen x:Name="PinsPane" DataContext="{Binding KnownHostsScreen}" />
|
|
</Panel>
|
|
|
|
<!-- ============ SNIPPETS ============ -->
|
|
<!-- Wrapped, like the others whose data context is their own view model. -->
|
|
<Panel IsVisible="{Binding IsSnippetsScreen}">
|
|
<views:SnippetsScreen x:Name="SnippetsPane" DataContext="{Binding SnippetsScreen}" />
|
|
</Panel>
|
|
|
|
<!-- ============ LOGS ============ -->
|
|
<!-- Wrapped, like the others whose data context is their own view model. -->
|
|
<Panel IsVisible="{Binding IsLogsScreen}">
|
|
<views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" />
|
|
</Panel>
|
|
|
|
<!-- ============ VAULTS ============ -->
|
|
<!--
|
|
Wrapped, for the reason the keychain and transfers screens are: the visibility is the shell's
|
|
business and the data context is the vaults view model, and both on one element would resolve
|
|
IsVaultsScreen against a type that does not have it.
|
|
|
|
Bound to Vaults, which is the vaults themselves and the people in them — not to Vault, which
|
|
is one vault's contents and is what the keychain and hosts screens above draw.
|
|
-->
|
|
<Panel IsVisible="{Binding IsVaultsScreen}">
|
|
<views:VaultsScreen DataContext="{Binding Vaults}" />
|
|
</Panel>
|
|
|
|
<!-- ============ PREFERENCES ============ -->
|
|
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
|
|
|
|
<!-- ============ IMPORT ============ -->
|
|
<!--
|
|
Reached from preferences rather than from the rail; see ShellScreen.Import. Wrapped, like
|
|
the others whose data context is their own view model.
|
|
-->
|
|
<Panel IsVisible="{Binding IsImportScreen}">
|
|
<views:ImportScreen x:Name="ImportPane" DataContext="{Binding ImportScreen}" />
|
|
</Panel>
|
|
|
|
</Panel>
|
|
|
|
<!--
|
|
The other thing that can be in the terminal's rectangle: a tab whose session does not exist
|
|
yet, or never will. Exclusive with the WebView below by construction — a selected tab either
|
|
has a session or it does not — which is what makes drawing it here safe under the occlusion
|
|
rule, the same way the page area is. See ConnectingCard.axaml.
|
|
-->
|
|
<views:ConnectingCard x:Name="ConnectingPane"
|
|
IsVisible="{Binding IsConnectingShowing, FallbackValue=False}" />
|
|
|
|
<!--
|
|
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser
|
|
process tree, so twenty tabs would cost twenty of them.
|
|
|
|
A sibling of the page area rather than a child of any screen, which is the structural half of
|
|
the tab rework: the terminal belongs to the window now, not to the hosts screen.
|
|
|
|
FallbackValue, because a compiled binding with no DataContext yields UnsetValue, IsVisible
|
|
then falls back to its default of true, and the occlusion comes back silently. Not reachable
|
|
at runtime — the DataContext is set before the window is shown — but it is what the previewer
|
|
does.
|
|
-->
|
|
<NativeWebView x:Name="Terminal"
|
|
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
|
|
|
</Panel>
|
|
</Grid>
|
|
</Grid>
|
|
|
|
<!--
|
|
Setup and unlock. Later in the Panel, so it is above the application content in Avalonia's z-order —
|
|
which covers Avalonia-drawn content and nothing else. The terminal is collapsed rather than covered;
|
|
see the occlusion rule above.
|
|
-->
|
|
<Border Background="{StaticResource Canvas}" IsVisible="{Binding !IsUnlocked}">
|
|
<Panel>
|
|
|
|
<Border Classes="card" IsVisible="{Binding IsStarting}">
|
|
<StackPanel Spacing="10">
|
|
<TextBlock Classes="heading" Text="DodoSSH" />
|
|
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<Border Classes="card" IsVisible="{Binding IsNeedingServer}">
|
|
<StackPanel Spacing="12">
|
|
<TextBlock Classes="heading" Text="Connect to your server" />
|
|
<TextBlock Classes="hint"
|
|
Text="One address is all this needs. The identity provider, the client id and the scopes all come from the server itself." />
|
|
<!--
|
|
Named because signing out lands here with the keyboard needing somewhere to go: the vault's
|
|
controls have just been collapsed, and Focus() on a collapsed control is a no-op that is
|
|
not replayed. See MainWindow.axaml.cs.
|
|
-->
|
|
<TextBox x:Name="ServerUrlBox" Text="{Binding ServerUrl}"
|
|
PlaceholderText="https://dodossh.example" />
|
|
<Button Classes="accent" Content="SIGN IN WITH YOUR BROWSER" Command="{Binding SignInCommand}"
|
|
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
|
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<Border Classes="card" IsVisible="{Binding IsNeedingEnrollment}">
|
|
<StackPanel Spacing="12">
|
|
<TextBlock Classes="heading" Text="Choose a keychain passphrase" />
|
|
<TextBlock Classes="hint"
|
|
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your keychain." />
|
|
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" />
|
|
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" />
|
|
<Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}"
|
|
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
|
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<!--
|
|
Shown once and impossible to skip. This is the only moment the code exists, and losing it
|
|
together with the passphrase means the keychain is unrecoverable — there is no server-side reset
|
|
by design.
|
|
-->
|
|
<Border Classes="card" IsVisible="{Binding IsShowingRecoveryCode}">
|
|
<StackPanel Spacing="12">
|
|
<TextBlock Classes="heading" Text="Write this recovery code down" />
|
|
<TextBlock Classes="hint"
|
|
Text="It is shown once and is not stored anywhere. Without it, forgetting your passphrase means losing the keychain: nobody — including whoever runs the server — can recover it for you." />
|
|
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
|
BorderThickness="1" CornerRadius="6" Padding="14">
|
|
<SelectableTextBlock Classes="mono" Text="{Binding RecoveryCode}"
|
|
FontSize="17" Foreground="{StaticResource Accent}"
|
|
TextWrapping="Wrap" />
|
|
</Border>
|
|
<CheckBox IsChecked="{Binding RecoveryCodeWrittenDown}"
|
|
Content="I have written it down somewhere safe" />
|
|
<Button Classes="accent" Content="CONTINUE" Command="{Binding ConfirmRecoveryCodeCommand}"
|
|
HorizontalAlignment="Left" />
|
|
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<!--
|
|
The unlock card, and the sign-out confirmation that replaces it. Both live in their own files:
|
|
nothing inside this window can be laid out by a test — WebView2's adapter refuses the headless
|
|
session's thread — so markup that stays here is markup nobody can measure. See UnlockCard.axaml.
|
|
-->
|
|
<Border Classes="card" IsVisible="{Binding IsAskingForThePassphrase}">
|
|
<views:UnlockCard x:Name="UnlockPane" />
|
|
</Border>
|
|
|
|
<!--
|
|
The confirmation, in place of the unlock card rather than under it: the card is already close
|
|
to the height the window guarantees at its minimum, and a screen a user cannot read all of is
|
|
worse than one that shows one question at a time.
|
|
-->
|
|
<Border Classes="card" IsVisible="{Binding IsConfirmingSignOut}">
|
|
<views:SignOutCard />
|
|
</Border>
|
|
|
|
</Panel>
|
|
</Border>
|
|
|
|
<!--
|
|
Last, so it is over everything Avalonia draws. It is over the terminal too, because opening it
|
|
collapses the terminal — see IsTerminalShowing.
|
|
-->
|
|
<views:QuickConnect x:Name="Palette" IsVisible="{Binding IsSearching}" />
|
|
|
|
</Panel>
|
|
|
|
<!--
|
|
A fourth row, and a row rather than an overlay for the reason the occlusion rule above gives: this
|
|
appears while a terminal may be open, and anything drawn in the WebView's rectangle is sliced. Taking
|
|
height from the row above moves the native control's bounds instead of covering it, which is the one
|
|
arrangement that works — the same one TitleBar and StatusBar already rely on.
|
|
|
|
It is a separate control because nothing in this file can be measured by a test, and a strip with two
|
|
buttons and a version string of unknown length is exactly the shape that arranges one of them off the
|
|
edge. See UpdateBanner.axaml.
|
|
|
|
FallbackValue, for the reason the WebView and the connecting card carry one: a compiled binding with
|
|
no DataContext yields UnsetValue, IsVisible falls back to true, and the previewer would show a banner
|
|
announcing an update that does not exist.
|
|
-->
|
|
<views:UpdateBanner Grid.Row="2"
|
|
IsVisible="{Binding Updates.IsBannerShowing, FallbackValue=False}" />
|
|
|
|
<views:StatusBar Grid.Row="3" />
|
|
|
|
</Grid>
|
|
|
|
</Window>
|