Public Access
Both screens had made the code selectable and both said why: a person who cannot get it out of the box photographs the screen, and a screenshot is a worse home for it than a clipboard. This finishes that argument. Selecting 64 characters of letter-spaced monospace with a thumb is the version of "possible" people give up on halfway — and on the phone the screen blocks screenshots, so the honest remaining options were retyping it or losing it. It is the one secret this application deliberately offers to a clipboard, and the contrast with the keychain's copy is the whole argument rather than an inconsistency. There, copying the private half is refused outright, because installing a key means pasting the public one and the private one has no business leaving the vault. Here there is no better route: the code exists for one screen, is stored nowhere, and has to reach a password manager. The clipboard is the intended destination rather than a way round the design. The sentence afterwards matters as much as the copy, and is asserted: a clipboard is a staging post, this screen is the only place the code exists, and the next thing copied replaces it. Somebody who copies and does nothing has not saved it. The phone had no clipboard delegate at all — the desktop passed one and this head passed null — so COPY PUBLIC KEY on the keychain answered "this machine has no clipboard" on a device that plainly has one. Nothing about that was platform shaped: Android has a clipboard and Avalonia surfaces it through the same TopLevel. Wiring it fixes that copy too. The test fixture built its shell without a clipboard, which modelled the bug rather than the product, so it has one now and the public-key test asserts what lands there instead of the refusal. The refusal keeps its own test, on a shell built without one, because the view model reads the delegate's absence rather than an empty result — and because a button that silently does nothing on this screen is worse than one that refuses.
234 lines
11 KiB
C#
234 lines
11 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.ApplicationLifetimes;
|
|
using Avalonia.Input.Platform;
|
|
using Avalonia.Markup.Xaml;
|
|
|
|
using DodoSSH.Client.Android.Platform;
|
|
using DodoSSH.Client.Android.Views;
|
|
using DodoSSH.Client.Auth;
|
|
using DodoSSH.Client.Session;
|
|
using DodoSSH.Client.Shell.Terminal;
|
|
using DodoSSH.Client.Shell.ViewModels;
|
|
using DodoSSH.Client.Ssh;
|
|
using DodoSSH.Client.Storage;
|
|
using DodoSSH.Client.Terminal;
|
|
|
|
namespace DodoSSH.Client.Android;
|
|
|
|
/// <summary>
|
|
/// The Avalonia application, phone side.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Named <c>DodoSshApp</c> for the same reason the desktop head's is, and then for a second reason on top
|
|
/// of it: a type called <c>App</c> in a namespace ending <c>.Android</c> is what makes every
|
|
/// <c>Android.App</c> in this assembly ambiguous. See the note at the top of MainActivity.
|
|
/// </remarks>
|
|
public sealed partial class DodoSshApp : Avalonia.Application
|
|
{
|
|
/// <inheritdoc />
|
|
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
|
|
|
/// <inheritdoc />
|
|
public override void OnFrameworkInitializationCompleted()
|
|
{
|
|
// ISingleViewApplicationLifetime, not IClassicDesktopStyleApplicationLifetime: a phone has one
|
|
// surface and no window to own. That difference is the whole reason the two heads cannot share a
|
|
// composition root, and very nearly the only one — everything either of them composes is the same.
|
|
if (ApplicationLifetime is ISingleViewApplicationLifetime single)
|
|
{
|
|
single.MainView = Compose();
|
|
}
|
|
|
|
base.OnFrameworkInitializationCompleted();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Composed by hand rather than through a container, matching the desktop head: the graph is a handful
|
|
/// of objects deep and an indirection to read through would buy nothing at this size. Read it beside
|
|
/// <c>DodoSSH.Client.App/App.axaml.cs</c> — the shape is deliberately identical, and the four
|
|
/// differences are the four things docs/android-port.md said would differ.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Nothing here is disposed on a lifecycle hook</b>, and that is not an oversight either. Android
|
|
/// does not promise to call anything before killing a process, so a teardown path would be a comfort
|
|
/// rather than a guarantee. What actually protects the sessions is the foreground service; what
|
|
/// protects the vault keys is that they never leave memory this process owns.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static PhoneShell Compose()
|
|
{
|
|
// Difference 1: the profile directory comes from the head. filesDir is per-app and non-roaming,
|
|
// which is what ClientPaths asks for and what no desktop platform guarantees.
|
|
var paths = PhoneEnvironment.Paths;
|
|
paths.EnsureCreated();
|
|
|
|
var caches = ClientCacheFactory.ForFile(paths.CacheFile);
|
|
|
|
var knownHosts = new VaultKnownHostStore();
|
|
var connections = new SshNetConnectionFactory(knownHosts);
|
|
|
|
var workspace = new TerminalWorkspace(
|
|
new AvaloniaTerminalAssetProvider(),
|
|
connections,
|
|
TimeProvider.System);
|
|
|
|
workspace.Start();
|
|
|
|
// Before anything can queue a transfer, which is the only moment at which emptying this is
|
|
// provably safe. What it clears is the copy a stopped upload leaves behind on purpose — kept so
|
|
// RESUME has something to read — and whatever a process death interrupted. See DocumentStaging.
|
|
DocumentStaging.Sweep();
|
|
|
|
// Built before the view model, because the clipboard is reached through it — see ClipboardWriter,
|
|
// which takes the surface and looks the TopLevel up on each call rather than now.
|
|
var shell = new PhoneShell();
|
|
|
|
var viewModel = ComposeShell(
|
|
paths, caches, workspace, knownHosts, connections, ClipboardWriter(shell));
|
|
|
|
shell.DataContext = viewModel;
|
|
|
|
// Difference 2: the foreground service, which is what makes TerminalWorkspace's promise — that a
|
|
// shell outlives a vault lock — true on a platform that stops backgrounded processes.
|
|
//
|
|
// The transfer count is real now that the document picker gives this head a way to start one, and
|
|
// it is the half that matters most here: a shell survives backgrounding because somebody is looking
|
|
// at it, and an upload has to survive precisely when nobody is — the screen is off and the phone is
|
|
// in a pocket. Queued counts as active, so putting five files in the queue and locking the phone
|
|
// moves five files.
|
|
//
|
|
// A local rather than a field, matching the desktop head: an Avalonia Application has no disposal
|
|
// hook, so a field holding a disposable would have nowhere honest to release it. It stays alive
|
|
// because it is subscribed to the workspace, which lives as long as the process.
|
|
var keepAlive = new SessionKeepAlive(
|
|
workspace,
|
|
activeTransfers: () => viewModel.Transfers.ActiveTransfers);
|
|
|
|
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
|
|
// announces transfers appearing and finishing. Without this the notification would come up when an
|
|
// upload started and stay up after it finished, which is the failure this class exists to prevent.
|
|
viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh();
|
|
|
|
keepAlive.Refresh();
|
|
|
|
return shell;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writing to this phone's clipboard.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ◆ <b>This head had none, and every control that wanted one said so out loud.</b> COPY PUBLIC KEY on
|
|
/// the keychain answered "This machine has no clipboard" on a device that plainly has one, because the
|
|
/// delegate was simply never wired here — the desktop passed one and the phone passed null. Android has
|
|
/// a clipboard and Avalonia surfaces it through the same <c>TopLevel</c> the desktop reaches, so there
|
|
/// was nothing platform-shaped about the gap.
|
|
/// </para>
|
|
/// <para>
|
|
/// Looked up per call rather than captured, exactly as the desktop's is: at composition there is no
|
|
/// <c>TopLevel</c> yet, because this is the method building the view it will be attached to. A machine
|
|
/// that somehow has none falls through silently and the view model decides what to say, which is what
|
|
/// keeps "no clipboard here" and "copied" different answers.
|
|
/// </para>
|
|
/// <para>
|
|
/// A delegate rather than an <c>IClipboard</c>, so nothing in the view models needs a visual and every
|
|
/// test that drives them stays window-free.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static Func<string, Task> ClipboardWriter(Visual surface) =>
|
|
async text =>
|
|
{
|
|
if (TopLevel.GetTopLevel(surface) is { Clipboard: { } clipboard })
|
|
{
|
|
await clipboard.SetTextAsync(text).ConfigureAwait(false);
|
|
}
|
|
};
|
|
|
|
/// <remarks>
|
|
/// Split from <see cref="Compose"/> only for length. The division is a real one though: above this is
|
|
/// the platform graph, and below it is the shell every head shares.
|
|
/// </remarks>
|
|
private static MainWindowViewModel ComposeShell(
|
|
ClientPaths paths,
|
|
ClientCacheFactory caches,
|
|
TerminalWorkspace workspace,
|
|
VaultKnownHostStore knownHosts,
|
|
SshNetConnectionFactory connections,
|
|
Func<string, Task> copyToClipboard)
|
|
{
|
|
// Difference 3: the Android keystore, with a fingerprint or the device credential releasing the
|
|
// key. A straight implementation of the interface the session layer has always taken.
|
|
var deviceKeys = new AndroidDeviceKeyStore(paths);
|
|
|
|
// Difference 6, and the newest: where newer builds come from. The same shape of question
|
|
// deviceKeys answers — a property of this installation, decided once, here — and the same answer
|
|
// when this copy was not installed by anything that can replace it. See ADR 0014.
|
|
//
|
|
// Its own HttpClient rather than the sync client's: this one talks to the project's forge and that
|
|
// one talks to the deployment, and the whole point of ADR 0011 rule 2 is that those are different
|
|
// parties. Sharing a handler would be one connection pool, one set of default headers and one
|
|
// place for a future change to leak a token from the second into the first. Never disposed, for
|
|
// the reason nothing else here is: an Avalonia Application has no disposal hook, and this lives as
|
|
// long as the process.
|
|
var updates = AndroidUpdateChannels.ForThisPhone(new HttpClient());
|
|
|
|
var browser = new AndroidBrowserLauncher();
|
|
|
|
var viewModel = new MainWindowViewModel(
|
|
paths,
|
|
caches,
|
|
workspace,
|
|
knownHosts,
|
|
deviceKeys,
|
|
|
|
// Difference 4: the system browser by intent, and the response by intent too rather than on a
|
|
// loopback socket. See AndroidAuthorization for why the desktop's listener is not reused.
|
|
async (url, cancellationToken) => await ServerConnection
|
|
.SignInAsync(url, browser, TimeProvider.System, cancellationToken, ConfigureAndroidOidc)
|
|
.ConfigureAwait(false),
|
|
|
|
TimeProvider.System,
|
|
connections,
|
|
passphraseProfile: null,
|
|
|
|
// The other half of signing in: a refresh grant, no browser, and nobody present. It is what
|
|
// makes a launch after the first one arrive online rather than merely enrolled.
|
|
resume: async (url, refreshToken, cancellationToken) => await ServerConnection
|
|
.ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken)
|
|
.ConfigureAwait(false),
|
|
|
|
// Difference 5: what this phone is called. The shell's default is Environment.MachineName, which
|
|
// answers localhost here — so without this the account's device list would show one localhost
|
|
// per phone, on the very screen a lost device is revoked from, and every log entry a phone wrote
|
|
// would name the same machine. See PhoneEnvironment.DeviceName.
|
|
deviceName: PhoneEnvironment.DeviceName,
|
|
|
|
copyToClipboard: copyToClipboard,
|
|
|
|
updates: updates);
|
|
|
|
// Started rather than awaited: framework initialisation must not block on a schema migration. The
|
|
// view model shows its own progress and handles its own failures.
|
|
_ = viewModel.StartAsync(CancellationToken.None);
|
|
|
|
return viewModel;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Difference 4: where the authorization response comes back to.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The only thing this head changes about signing in, and it changes it for a security reason rather
|
|
/// than a platform one. The desktop receives the response on a loopback <c>TcpListener</c>; on a phone
|
|
/// any other installed application can bind a loopback port and race for the authorization code, which
|
|
/// is the attack RFC 8252 §8.3 names. Android routes a registered redirect to this application
|
|
/// instead — see <see cref="AndroidRedirectCallback"/>, including what a private-use scheme does and
|
|
/// does not protect against.
|
|
/// </remarks>
|
|
private static OidcClientOptions ConfigureAndroidOidc(OidcClientOptions options) =>
|
|
options with { CallbackFactory = path => new AndroidRedirectCallback(path) };
|
|
}
|