Give DodoSSH a phone, and a shared shell for both heads to drive

The Android head from docs/android-port.md, taken as far as its step 6.

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

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

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

Sign-in is deliberately absent rather than approximated. It needs an app link, because
reusing the desktop loopback listener is the attack RFC 8252 section 8.3 names.
This commit is contained in:
2026-07-31 20:58:48 +02:00
parent 03e902a2d2
commit fe9d7fc289
65 changed files with 3034 additions and 103 deletions
+5
View File
@@ -108,6 +108,11 @@
--> -->
<PackageVersion Include="Avalonia" Version="12.1.1" /> <PackageVersion Include="Avalonia" Version="12.1.1" />
<PackageVersion Include="Avalonia.Desktop" Version="12.1.1" /> <PackageVersion Include="Avalonia.Desktop" Version="12.1.1" />
<!--
The Android head. Same core version as the desktop one, which is not a courtesy: the two heads
share every view model, so a version skew between them would be a skew inside one object graph.
-->
<PackageVersion Include="Avalonia.Android" Version="12.1.1" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.1" /> <PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.1" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.1" /> <PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.1" />
<PackageVersion Include="Avalonia.Controls.WebView" Version="12.0.1" /> <PackageVersion Include="Avalonia.Controls.WebView" Version="12.0.1" />
+1
View File
@@ -21,6 +21,7 @@
<Project Path="src/DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj" /> <Project Path="src/DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj" />
<Project Path="src/DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj" /> <Project Path="src/DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj" />
<Project Path="src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" /> <Project Path="src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
<Project Path="src/DodoSSH.Client.Shell/DodoSSH.Client.Shell.csproj" />
<Project Path="src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" /> <Project Path="src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
<Project Path="src/DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj" /> <Project Path="src/DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj" />
<Project Path="src/DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj" /> <Project Path="src/DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj" />
+26
View File
@@ -0,0 +1,26 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="DodoSSH.Client.Android.DodoSshApp"
RequestedThemeVariant="Dark">
<!--
Dark, and not following the system. Same reasoning as the desktop head: a terminal is a dark surface
either way, and a light chrome around a dark terminal is the worst of both. On a phone there is a
second reason — the theme in Resources/values/styles.xml paints the window, the status bar and the
navigation bar before Avalonia draws a frame, and it can only be one of the two.
-->
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<!-- The same palette the desktop head draws with. See DodoSSH.Client.Shell/Theme/Palette.axaml. -->
<ResourceInclude Source="avares://DodoSSH.Client.Shell/Theme/Palette.axaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
+134
View File
@@ -0,0 +1,134 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using DodoSSH.Client.Android.Platform;
using DodoSSH.Client.Android.Views;
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();
// 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.
// Zero transfers for now: file transfer is out of this head's first scope by decision, and this is
// the seam it arrives through rather than a dependency taken before there is anything behind it.
// 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.
//
// Refresh() is called once here. Calling it again when a shell opens is what the terminal screen
// will wire, and there is nothing to wire it to yet — the workspace announces sessions ending on
// its own, which is the half that would otherwise leave a notification up over nothing.
var keepAlive = new SessionKeepAlive(workspace, activeTransfers: () => 0);
// 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);
var viewModel = new MainWindowViewModel(
paths,
caches,
workspace,
knownHosts,
deviceKeys,
SignInIsNotBuiltHere,
TimeProvider.System,
connections,
passphraseProfile: null);
// 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);
keepAlive.Refresh();
return new PhoneShell { DataContext = viewModel };
}
/// <summary>
/// Difference 4, and the one that is a refusal rather than an implementation.
/// </summary>
/// <remarks>
/// <para>
/// Signing in needs a redirect this head has not got. The desktop client receives the authorization
/// response on a loopback <c>TcpListener</c> (RFC 8252 §7.3), and reusing that here would be a
/// security regression rather than a shortcut: on a shared device any other application can bind a
/// loopback port and race for the response, which is the attack §8.3 names and the reason app links
/// exist. <c>Process.Start</c> does not exist on this platform either.
/// </para>
/// <para>
/// So this throws rather than half-working, and the shell never reaches it: <c>NeedsServer</c> draws a
/// screen that says the same thing in the user's words. A phone enrolled from the desktop client
/// unlocks here perfectly well, because unlocking needs no network at all.
/// </para>
/// </remarks>
private static Task<IVaultServer> SignInIsNotBuiltHere(Uri serverUrl, CancellationToken cancellationToken) =>
throw new NotSupportedException(
"Signing in is not built on the Android head yet: it needs an app-link redirect rather than "
+ "the desktop client's loopback listener. See docs/android-port.md §5.");
}
@@ -0,0 +1,54 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-android</TargetFramework>
<OutputType>Exe</OutputType>
<Nullable>enable</Nullable>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!--
minSdk 28 (Android 9), targetSdk 36. Chosen rather than inherited, because docs/android-port.md
flagged it as a real decision: the packages constrain targetSdk, not the floor.
28 is where the three platform APIs this head actually depends on all exist in the framework
itself rather than behind an AndroidX shim: BiometricPrompt, StrongBox-backed keys, and
KeyGenParameterSpec.SetUnlockedDeviceRequired. Going lower would mean carrying androidx.biometric
to reach devices that mostly cannot hold a hardware-backed key anyway — which is the one thing
the device key store is for.
-->
<SupportedOSPlatformVersion>28</SupportedOSPlatformVersion>
<TargetPlatformVersion>36</TargetPlatformVersion>
<ApplicationId>dev.dodotech.dodossh</ApplicationId>
<ApplicationVersion>1</ApplicationVersion>
<ApplicationDisplayVersion>0.1.0</ApplicationDisplayVersion>
<!--
False here for the same reason the desktop head sets it false: this process formats timestamps
and host names for a person. See DodoSSH.Client.App.csproj.
-->
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Android" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Avalonia.Controls.WebView" />
<PackageReference Include="CommunityToolkit.Mvvm" />
</ItemGroup>
<ItemGroup>
<!--
The same view models the desktop head drives. That sharing is the whole reason this head is a
sibling project rather than a fork: docs/android-port.md's phone-first decision says the interface
is a redesign, and that what survives it is every view model, command and piece of state.
-->
<ProjectReference Include="../DodoSSH.Client.Shell/DodoSSH.Client.Shell.csproj" />
<ProjectReference Include="../DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
<ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,53 @@
// See the note at the top of MainActivity for why the platform namespaces are reached through `global::`.
using global::Android.App;
using global::Android.Runtime;
using Avalonia;
using Avalonia.Android;
using DodoSSH.Client.Android.Platform;
namespace DodoSSH.Client.Android;
/// <summary>
/// The Android <c>Application</c> object, and where Avalonia is configured.
/// </summary>
/// <remarks>
/// <para>
/// Avalonia 12 moved the <see cref="AppBuilder"/> hooks here from the activity, which is a better fit than
/// it first looks: an activity is destroyed and recreated, and the application object is not. The desktop
/// head's equivalent is <c>Program.BuildAvaloniaApp</c>.
/// </para>
/// <para>
/// Any Avalonia 11 sample will show this on <c>AvaloniaMainActivity&lt;App&gt;</c> instead. That type is
/// still here and is still what the launcher activity derives from — it simply no longer takes the
/// application as a type argument.
/// </para>
/// </remarks>
[Application(Label = "DodoSSH")]
public sealed class DodoSshAndroidApplication : AvaloniaAndroidApplication<DodoSshApp>
{
/// <remarks>
/// The JNI constructor, and the only one Android calls. It exists to hand the managed object its Java
/// peer; there is nothing to do in it and nothing may be done in it, because the application context is
/// not usable until <see cref="OnCreate"/>.
/// </remarks>
public DodoSshAndroidApplication(nint javaReference, JniHandleOwnership transfer)
: base(javaReference, transfer)
{
}
/// <inheritdoc />
public override void OnCreate()
{
// Before Avalonia, and from the application rather than the activity: this is the context whose
// lifetime matches the composition root's, and reading filesDir is the first thing startup does.
PhoneEnvironment.Attach(this);
base.OnCreate();
}
/// <inheritdoc />
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) =>
base.CustomizeAppBuilder(builder).WithInterFont();
}
@@ -0,0 +1,50 @@
// The Android SDK's own namespaces are reached through `global::` throughout this project, and it is not
// a style choice. This assembly's root namespace ends in `Android`, so inside it a bare `Android.App`
// binds to `DodoSSH.Client.Android.App` — this head's own Avalonia application type — rather than to the
// platform. The desktop head hit the same class of collision and answered it by renaming its type; here
// the collision is in the namespace itself, so the qualification is the honest fix.
using global::Android.App;
using global::Android.Content.PM;
using Avalonia.Android;
namespace DodoSSH.Client.Android;
/// <summary>
/// The launcher activity.
/// </summary>
/// <remarks>
/// <para>
/// Deliberately empty. Avalonia is configured on the application object — see
/// <see cref="DodoSshAndroidApplication"/> — and the desktop head's <c>Program.Main</c> has no counterpart
/// at all here: Android constructs the activity, and its <c>[STAThread]</c> is a WebView2 requirement that
/// means nothing on this platform.
/// </para>
/// <para>
/// <b>The ConfigurationChanges list is load-bearing.</b> Without it Android destroys and recreates the
/// activity on every rotation and every time the software keyboard appears — and this head holds live SSH
/// sessions and a terminal data plane behind a process-wide composition root. Letting the activity restart
/// would tear the Avalonia application down under them. Declaring the changes handled is what keeps a
/// shell alive across turning the phone sideways, which is the same promise the foreground service makes
/// about backgrounding, arrived at from a different direction.
/// </para>
/// <para>
/// <c>SingleTask</c> for the sign-in redirect: the authorization response comes back as an intent, and any
/// 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>
/// </remarks>
[Activity(
Label = "DodoSSH",
Theme = "@style/DodoTheme",
MainLauncher = true,
LaunchMode = LaunchMode.SingleTask,
ConfigurationChanges = ConfigChanges.Orientation
| ConfigChanges.ScreenSize
| ConfigChanges.ScreenLayout
| ConfigChanges.SmallestScreenSize
| ConfigChanges.KeyboardHidden
| ConfigChanges.UiMode)]
public sealed class MainActivity : AvaloniaMainActivity
{
}
@@ -0,0 +1,223 @@
using global::Android.App;
using global::Android.Security.Keystore;
using global::Java.Security;
using global::Javax.Crypto;
using global::Javax.Crypto.Spec;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
/// This phone's device key, wrapped by a hardware-held key that a fingerprint releases.
/// </summary>
/// <remarks>
/// <para>
/// The Android counterpart of the desktop head's <c>WindowsDeviceKeyStore</c>, and a closer match to what
/// the unlock screen wants than that one is: the Windows store leans on DPAPI plus a TPM-held key and a
/// Hello gesture, whereas here the gesture is a property of the key itself and the platform will not
/// release it without one. See ADR 0007 and docs/android-port.md §4.
/// </para>
/// <para>
/// <b>The X25519 scalar is not stored in the keystore</b>, and cannot be: Android's keystore holds keys it
/// generates and refuses to export them, while what the vault needs back is the raw 32 bytes. So the
/// keystore holds an AES-GCM key that never leaves the secure hardware, and that key encrypts the scalar
/// into an ordinary file beside the cache. The file is useless on its own — on this phone as much as on
/// any other — which is the same shape the Windows store already has.
/// </para>
/// <para>
/// <b>StrongBox is asked for and not required.</b> Where the phone has a separate security chip the key
/// lives there; where it does not, generation throws
/// <see cref="StrongBoxUnavailableException"/> and the key is made in the TEE instead. Refusing to fall
/// back would mean a mid-range phone reporting no device key at all, which costs a real user a real
/// feature to buy a distinction the threat model does not draw.
/// </para>
/// <para>
/// <b>Every failure here is answered by returning null rather than throwing</b>, exactly as the interface
/// asks. A user can cancel the prompt, a re-enrolled fingerprint invalidates the key permanently, and a
/// phone can have no enrolled biometric at all — and the caller's answer to all three is the same one:
/// ask for the passphrase.
/// </para>
/// </remarks>
internal sealed class AndroidDeviceKeyStore(ClientPaths paths) : IDeviceKeyStore
{
private const string KeystoreName = "AndroidKeyStore";
private const string KeyAlias = "dodossh.device";
private const string Transformation = "AES/GCM/NoPadding";
/// <summary>
/// The nonce is stored ahead of the ciphertext rather than derived.
/// </summary>
/// <remarks>
/// The cipher picks it: a GCM key that is asked to encrypt twice under a caller-chosen nonce is one
/// misuse away from losing the key, and Android's keystore refuses a caller-supplied IV for exactly
/// that reason. Twelve bytes is what it generates.
/// </remarks>
private const int NonceBytes = 12;
/// <inheritdoc />
/// <remarks>
/// Three things have to be true, and the third is the one that is easy to forget: the platform has a
/// keystore, the phone has a screen lock, and something is actually enrolled to satisfy it. A phone
/// with no lock screen can still generate a key that requires authentication — and then no gesture can
/// ever release it.
/// </remarks>
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken)
{
try
{
var keyguard = PhoneEnvironment.Require()
.GetSystemService(global::Android.Content.Context.KeyguardService) as KeyguardManager;
return ValueTask.FromResult(keyguard?.IsDeviceSecure == true);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
return ValueTask.FromResult(false);
}
}
/// <inheritdoc />
public async ValueTask SaveAsync(ReadOnlyMemory<byte> devicePrivateKey, CancellationToken cancellationToken)
{
var key = GenerateWrappingKey();
var cipher = Cipher.GetInstance(Transformation)
?? throw new InvalidOperationException("This phone has no AES/GCM/NoPadding cipher.");
cipher.Init(CipherMode.EncryptMode, key);
// Authenticated before the key is usable, exactly as loading is. Registering a device is itself a
// decision worth a gesture — it is the moment this phone gains the ability to open the vault
// without the passphrase.
await BiometricGate
.AuthenticateAsync(cipher, "Register this phone", cancellationToken)
.ConfigureAwait(false);
var sealed_ = cipher.DoFinal(devicePrivateKey.ToArray())
?? throw new InvalidOperationException("The keystore cipher returned nothing.");
var nonce = cipher.GetIV() ?? throw new InvalidOperationException("The keystore cipher chose no IV.");
var blob = new byte[nonce.Length + sealed_.Length];
nonce.CopyTo(blob, 0);
sealed_.CopyTo(blob, nonce.Length);
paths.EnsureCreated();
await File.WriteAllBytesAsync(paths.DeviceKeyFile, blob, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
{
try
{
if (!File.Exists(paths.DeviceKeyFile))
{
return null;
}
var blob = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken).ConfigureAwait(false);
if (blob.Length <= NonceBytes)
{
return null;
}
var store = KeyStore.GetInstance(KeystoreName)
?? throw new InvalidOperationException("This phone has no AndroidKeyStore.");
store.Load(null);
// Null when the key was invalidated — a re-enrolled fingerprint or a reset screen lock does
// this, and it is permanent by design. The wrapped file is unopenable from here on, so the
// honest answer is the same as having no key: ask for the passphrase.
if (store.GetKey(KeyAlias, null) is not IKey key)
{
return null;
}
var cipher = Cipher.GetInstance(Transformation)
?? throw new InvalidOperationException("This phone has no AES/GCM/NoPadding cipher.");
cipher.Init(CipherMode.DecryptMode, key, new GCMParameterSpec(128, blob, 0, NonceBytes));
await BiometricGate
.AuthenticateAsync(cipher, "Unlock DodoSSH", cancellationToken)
.ConfigureAwait(false);
return cipher.DoFinal(blob, NonceBytes, blob.Length - NonceBytes);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Deliberately flat. KeyPermanentlyInvalidatedException, UserNotAuthenticatedException, a
// cancelled prompt and a truncated file are four different stories with one ending, and the
// interface says so: distinguishing them would describe the keystore rather than tell the user
// anything they can act on.
return null;
}
}
/// <inheritdoc />
public ValueTask ForgetAsync(CancellationToken cancellationToken)
{
try
{
var store = KeyStore.GetInstance(KeystoreName);
store?.Load(null);
store?.DeleteEntry(KeyAlias);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Best effort: the file going is what actually withdraws this phone's ability to unlock, and
// an orphaned keystore entry opens nothing.
}
// After the keystore entry, not before. A file left behind with its key already deleted is merely
// unopenable; a key left behind with its file already gone is the same. Order costs nothing here,
// but the file is the one that matters, so it goes last and unconditionally.
if (File.Exists(paths.DeviceKeyFile))
{
File.Delete(paths.DeviceKeyFile);
}
return ValueTask.CompletedTask;
}
/// <remarks>
/// <c>SetInvalidatedByBiometricEnrollment</c> is on, which is the setting that makes this worth having:
/// without it, somebody who can add their own fingerprint to an unlocked phone inherits the ability to
/// unlock the vault. With it, enrolling a new fingerprint destroys the key and the phone falls back to
/// the passphrase — which is the correct outcome and the reason the load path treats invalidation as
/// ordinary rather than exceptional.
/// </remarks>
private static IKey GenerateWrappingKey()
{
static KeyGenParameterSpec.Builder Spec() =>
new KeyGenParameterSpec.Builder(KeyAlias, KeyStorePurpose.Encrypt | KeyStorePurpose.Decrypt)
.SetBlockModes(KeyProperties.BlockModeGcm)!
.SetEncryptionPaddings(KeyProperties.EncryptionPaddingNone)!
.SetKeySize(256)!
.SetUserAuthenticationRequired(true)!
.SetInvalidatedByBiometricEnrollment(true)!;
var generator = KeyGenerator.GetInstance(KeyProperties.KeyAlgorithmAes, KeystoreName)
?? throw new InvalidOperationException("This phone has no AES key generator in its keystore.");
try
{
generator.Init(Spec().SetIsStrongBoxBacked(true)!.Build());
return generator.GenerateKey()!;
}
catch (StrongBoxUnavailableException)
{
// No separate security chip. The TEE-backed key is still hardware-held and still gated by the
// same gesture; see the class remarks for why this is a fallback rather than a refusal.
generator.Init(Spec().Build());
return generator.GenerateKey()!;
}
}
}
@@ -0,0 +1,108 @@
using global::Android.Hardware.Biometrics;
using global::Javax.Crypto;
namespace DodoSSH.Client.Android.Platform;
/// <summary>Raised when the gesture did not happen — cancelled, failed, or nothing enrolled.</summary>
/// <remarks>
/// One exception for every refusal, because <see cref="AndroidDeviceKeyStore"/> answers all of them the
/// same way. It carries the platform's own message only so it can reach a log; nothing shows it to a user,
/// who has just watched the system's own dialogue say the same thing better.
/// </remarks>
internal sealed class BiometricRefusedException(string message) : Exception(message);
/// <summary>
/// Puts the system's biometric prompt in front of a cipher, and waits for it.
/// </summary>
/// <remarks>
/// <para>
/// The cipher is handed to the prompt rather than merely being used after it, and that is the whole point.
/// A prompt that only returned "yes" would be a boolean this process could be tricked into skipping;
/// binding the cipher to the prompt means the keystore itself will not perform the operation unless the
/// gesture actually happened. The key is unusable to a caller that did not go through here.
/// </para>
/// <para>
/// Separated from the store because it is the platform half and it is callback-shaped, and because it is
/// the piece most likely to need a second implementation — androidx.biometric, if the floor ever drops
/// below API 28.
/// </para>
/// </remarks>
internal static class BiometricGate
{
public static async Task AuthenticateAsync(Cipher cipher, string title, CancellationToken cancellationToken)
{
var context = PhoneEnvironment.Require();
var builder = new BiometricPrompt.Builder(context)
.SetTitle(title)!
.SetDescription("Releases this phone's device key. The vault itself never leaves it.")!;
if (OperatingSystem.IsAndroidVersionAtLeast(30))
{
// Device credential beside biometrics deliberately: a phone whose fingerprint reader is wet,
// or whose owner has none enrolled, still has a PIN, and the key is guarded either way. The
// alternative is an unlock screen that silently stops offering the fast path.
// BiometricManagerAuthenticators, not BiometricManager.Authenticators: .NET for Android
// flattens Java's nested classes, so the Java documentation's name is not the C# one.
// Cast because the binding types the flags as an enum and the setter as the raw int Java uses.
builder.SetAllowedAuthenticators(
(int)(BiometricManagerAuthenticators.BiometricStrong
| BiometricManagerAuthenticators.DeviceCredential));
}
else
{
// API 28 and 29 have no allowed-authenticators list, and a prompt with no negative button is
// rejected outright at build time. The button is the only way out of the dialogue on these two
// releases, which is why it says what it does.
builder.SetNegativeButton(
"Use passphrase",
context.MainExecutor!,
new RefusalListener());
}
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var signal = new global::Android.OS.CancellationSignal();
// The token has to reach the dialogue, not merely the await. Without this a cancelled unlock leaves
// the system prompt on screen over an application that has stopped waiting for it.
using var registration = cancellationToken.Register(signal.Cancel);
builder.Build().Authenticate(
new BiometricPrompt.CryptoObject(cipher),
signal,
context.MainExecutor!,
new Callback(completion));
// Awaited here rather than returned, so the cancellation registration above outlives the prompt
// it is there to cancel.
await completion.Task.ConfigureAwait(false);
}
private sealed class Callback(TaskCompletionSource completion) : BiometricPrompt.AuthenticationCallback
{
public override void OnAuthenticationSucceeded(BiometricPrompt.AuthenticationResult? result) =>
completion.TrySetResult();
/// <remarks>
/// Terminal, unlike <c>OnAuthenticationFailed</c>. An error is the prompt giving up — cancelled,
/// locked out, nothing enrolled — whereas a failure is one finger not being recognised, and the
/// prompt stays up and keeps trying after it. Completing the task on a failure would abandon a
/// dialogue that is still on screen.
/// </remarks>
public override void OnAuthenticationError(BiometricErrorCode errorCode, global::Java.Lang.ICharSequence? errString) =>
completion.TrySetException(
new BiometricRefusedException(errString?.ToString() ?? $"Biometric error {errorCode}."));
}
private sealed class RefusalListener : global::Java.Lang.Object, global::Android.Content.IDialogInterfaceOnClickListener
{
/// <remarks>
/// Nothing to do: dismissing the prompt raises <c>OnAuthenticationError</c> as well, and that is
/// where the wait is completed. Answering here too would be a second completion on the same task.
/// </remarks>
public void OnClick(global::Android.Content.IDialogInterface? dialog, int which)
{
}
}
}
@@ -0,0 +1,85 @@
using global::Android.Content;
using global::Android.OS;
using global::Android.Provider;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
/// The handful of facts about this phone that the platform-neutral layers need handed to them.
/// </summary>
/// <remarks>
/// <para>
/// Named <c>Phone</c> rather than <c>Android</c> because <c>Android.Runtime.AndroidEnvironment</c> already
/// exists and this type is not it.
/// </para>
/// <para>
/// Both members below are things docs/android-port.md called out as needing to come from the head rather
/// than be branched for inside the core: the profile directory, and a device name that is not
/// <c>Environment.MachineName</c>.
/// </para>
/// </remarks>
internal static class PhoneEnvironment
{
private static Context? context;
/// <summary>Captured once, from the launcher activity, before Avalonia starts.</summary>
/// <remarks>
/// The <em>application</em> context rather than the activity's. An activity is destroyed and recreated
/// on configuration changes this head does not declare as handled, and holding one in a static field is
/// the textbook Android leak; the application context lives as long as the process, which is exactly the
/// lifetime the composition root has.
/// </remarks>
public static void Attach(Context activity) =>
context = activity.ApplicationContext ?? activity;
/// <summary>Where this phone keeps its profile.</summary>
/// <remarks>
/// <c>filesDir</c> — per-app, non-roaming, not user-visible, and removed when the app is uninstalled.
/// <see cref="ClientPaths"/> asks for a local, non-roaming directory because two machines sharing one
/// cache file corrupts the outbox; on Android that is not merely satisfied but enforced by the platform,
/// since no other app can reach this path at all.
/// </remarks>
public static ClientPaths Paths =>
new(Require().FilesDir?.AbsolutePath
?? throw new InvalidOperationException("Android returned no filesDir for this application."));
/// <summary>
/// What this phone calls itself in connection and keychain log entries.
/// </summary>
/// <remarks>
/// <para>
/// <c>Environment.MachineName</c> returns <c>localhost</c> on Android, which would make every log entry
/// written from a phone indistinguishable from every other — the finding recorded in
/// docs/android-port.md §7.
/// </para>
/// <para>
/// <c>Settings.Global.DeviceName</c> is what the user themselves typed in Settings, so it is the name
/// they will recognise in a log written by a different device. It is null on phones that have never had
/// one set, and the fallback is the marketing model rather than the board name: a person reading a log
/// knows what a Pixel 8 is and does not know what <c>shiba</c> is.
/// </para>
/// </remarks>
public static string DeviceName
{
get
{
var chosen = Settings.Global.GetString(Require().ContentResolver, "device_name");
if (!string.IsNullOrWhiteSpace(chosen))
{
return chosen;
}
var model = Build.Model;
return string.IsNullOrWhiteSpace(model) ? "Android phone" : model;
}
}
/// <summary>The application context, once <see cref="Attach"/> has run.</summary>
public static Context Require() =>
context ?? throw new InvalidOperationException(
"PhoneEnvironment was read before MainActivity attached it.");
}
@@ -0,0 +1,134 @@
using global::Android.App;
using global::Android.Content;
using global::Android.Content.PM;
using global::Android.OS;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
/// Keeps the process alive for as long as a shell or a transfer is live.
/// </summary>
/// <remarks>
/// <para>
/// The decision recorded in docs/android-port.md: a persistent notification, for as long as there is
/// something running that would be wrong to kill. It costs the user a notification and some battery, and it
/// buys the behaviour the desktop client already promises and documents — that a shell outlives a vault
/// lock, and that a transfer finishes.
/// </para>
/// <para>
/// <b>Why this exists at all is worth stating plainly.</b> <c>TerminalWorkspace</c>'s guarantee is that
/// locking the vault does not close your shells, because the remote host never consulted the vault and the
/// credential was already spent. On a desktop that guarantee is free — the process keeps running. On
/// Android nothing keeps a backgrounded process running, so without this the guarantee would quietly become
/// desktop-only, and a phone would drop a shell the moment the user checked a message.
/// </para>
/// <para>
/// <b>It holds no state and owns nothing.</b> The sessions live in the composition root, exactly as they do
/// on the desktop; this only asks Android not to stop the process they are in. That is why starting and
/// stopping it is a count of live things rather than a lifecycle of its own — see
/// <see cref="SessionKeepAlive"/>.
/// </para>
/// </remarks>
[Service(
Exported = false,
// Android 14 (API 34) refuses to start a foreground service whose type is not declared both here and
// in the manifest's permission list. dataSync is the type that matches: an SSH session and a file
// transfer are both the user's data moving to somewhere the user chose.
ForegroundServiceType = ForegroundService.TypeDataSync)]
internal sealed class SessionForegroundService : Service
{
private const string ChannelId = "dodossh.sessions";
private const int NotificationId = 1;
/// <remarks>
/// A bound service would tie the sessions' lifetime to a binding, which is the opposite of what is
/// wanted here: the point is that they outlive whatever the user does with the interface.
/// </remarks>
public override IBinder? OnBind(Intent? intent) => null;
public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId)
{
StartForeground(NotificationId, BuildNotification(intent?.GetStringExtra("summary") ?? "Working"));
// NotSticky: if Android does kill this process, the SSH connections died with it and there is
// nothing to resume. Restarting the service would produce a notification claiming sessions that no
// longer exist, which is exactly the kind of dishonest state the unlock screen's shell count exists
// to prevent.
return StartCommandResult.NotSticky;
}
/// <remarks>
/// Low importance on purpose. This notification is a receipt, not an alert — it exists because Android
/// requires one, and because the user is entitled to know the app is holding connections open. Making
/// it buzz would be a notification about nothing having happened.
/// </remarks>
private Notification BuildNotification(string summary)
{
var manager = (NotificationManager)GetSystemService(NotificationService)!;
if (OperatingSystem.IsAndroidVersionAtLeast(26))
{
var channel = new NotificationChannel(ChannelId, "Live sessions", NotificationImportance.Low)
{
Description = "Shown while a shell or a transfer is open.",
};
channel.SetShowBadge(false);
manager.CreateNotificationChannel(channel);
}
var reopen = PendingIntent.GetActivity(
this,
0,
new Intent(this, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop),
PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent);
return new Notification.Builder(this, ChannelId)
.SetContentTitle("DodoSSH")
.SetContentText(summary)
.SetSmallIcon(global::Android.Resource.Drawable.IcDialogInfo)
.SetContentIntent(reopen)
.SetOngoing(true)!
.Build();
}
/// <summary>Starts or stops the service to match what is actually running.</summary>
/// <param name="liveSessions">Shells with a live channel behind them.</param>
/// <param name="activeTransfers">Transfers still moving bytes.</param>
public static void Reconcile(int liveSessions, int activeTransfers)
{
var context = PhoneEnvironment.Require();
var intent = new Intent(context, typeof(SessionForegroundService));
if (liveSessions == 0 && activeTransfers == 0)
{
context.StopService(intent);
return;
}
// The summary says what is actually held, counted rather than generic — the same principle the
// delete confirmations follow. "DodoSSH is running" would tell the user nothing they could act on.
intent.PutExtra("summary", Summarise(liveSessions, activeTransfers));
context.StartForegroundService(intent);
}
private static string Summarise(int liveSessions, int activeTransfers)
{
var parts = new List<string>(2);
if (liveSessions > 0)
{
parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected");
}
if (activeTransfers > 0)
{
parts.Add(activeTransfers == 1 ? "1 transfer running" : $"{activeTransfers} transfers running");
}
return string.Join(" · ", parts);
}
}
@@ -0,0 +1,63 @@
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
/// Keeps <see cref="SessionForegroundService"/> in step with what is actually running.
/// </summary>
/// <remarks>
/// <para>
/// The service is started and stopped from one place, and that place is a count rather than a lifecycle.
/// Anything else drifts: a service started when a shell opens and stopped when a tab closes would leave
/// the notification up after the last shell died on its own, and a phone showing "1 shell connected" over
/// nothing is the same dishonesty the unlock screen's shell count exists to avoid.
/// </para>
/// <para>
/// <see cref="TerminalWorkspace.LiveSessionCount"/> is deliberately the source of truth rather than a
/// tally kept here. It already knows that a session whose shell exited half an hour ago is not live, which
/// a counter incremented on open and decremented on close would not.
/// </para>
/// </remarks>
internal sealed class SessionKeepAlive : IDisposable
{
private readonly TerminalWorkspace workspace;
private readonly Func<int> activeTransfers;
/// <param name="workspace">The live shells.</param>
/// <param name="activeTransfers">
/// How many transfers are moving bytes. A delegate rather than a queue, because file transfer is out
/// of this head's first scope — see the decision in docs/android-port.md — and this is the seam it
/// will arrive through rather than a dependency taken before there is anything to depend on.
/// </param>
public SessionKeepAlive(TerminalWorkspace workspace, Func<int> activeTransfers)
{
this.workspace = workspace;
this.activeTransfers = activeTransfers;
// Raised on whatever thread the pump unwound on, which is fine: starting and stopping a service is
// a binder call and needs no particular thread. Nothing here touches the interface.
workspace.SessionEnded += OnSessionEnded;
}
/// <summary>Re-reads the counts and starts or stops the service to match.</summary>
/// <remarks>
/// Called after anything that could change either count — opening a shell, closing a tab, a transfer
/// finishing. Calling it when nothing changed is free: reconciling to the state it is already in is
/// either a redundant <c>startForegroundService</c> on a running service or a <c>stopService</c> on a
/// stopped one, and Android treats both as no-ops.
/// </remarks>
public void Refresh() =>
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers());
/// <inheritdoc />
public void Dispose()
{
workspace.SessionEnded -= OnSessionEnded;
// The notification goes with the composition root. Leaving it up over a process that is shutting
// down is how an SSH client acquires a reputation for a notification you cannot get rid of.
SessionForegroundService.Reconcile(0, 0);
}
private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh();
}
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- SSH itself, the sync client, and the loopback socket the terminal renderer attaches to. -->
<uses-permission android:name="android.permission.INTERNET" />
<!--
The foreground service that keeps shells and transfers alive across backgrounding — the decision
recorded in docs/android-port.md. dataSync is the type that matches what it actually does; Android 14
(API 34) rejects a service that starts without one declared here and on the <service> element.
-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- The service's persistent notification. Runtime-requested on API 33+, and refusal is survivable. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Releases the device key. See AndroidDeviceKeyStore. -->
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<!--
allowBackup and fullBackupContent are both off deliberately, and both are vault properties rather
than defaults worth inheriting. The local cache is a SQLite file holding the ciphertext mirror, the
outbox and the offline unlock material; letting Android back it up would copy vault material into a
Google-held backup that this product's threat model says nothing about. It would also restore one
phone's outbox onto another, which is the same corruption ClientPaths already refuses by insisting on
a local, non-roaming directory.
-->
<application android:label="DodoSSH"
android:theme="@style/DodoTheme"
android:networkSecurityConfig="@xml/network_security_config"
android:allowBackup="false"
android:fullBackupContent="false" />
</manifest>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
The window background, the status bar and the navigation bar, all one colour and all the same one
Avalonia paints its canvas with. This is what the system draws before a single frame of the
application exists, so a mismatch here is a white flash on every cold start of a dark app.
Kept in step with the Canvas brush in Theme/Palette.axaml by hand — there is no way to share a value
between an Android resource and a XAML resource dictionary, so the duplication is stated rather than
hidden.
-->
<color name="dodo_window">#0A0C0B</color>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
No action bar: this head draws its own header, because the design's header carries the vault name and
the sync dot, which a system action bar has nowhere to put.
windowLightStatusBar is false so the clock and the battery icon are drawn light. The design's status
strip is #7E8A84 text on near-black, and leaving this at its default renders dark-on-dark — legible
on the mock-up and invisible on a device.
-->
<style name="DodoTheme" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/dodo_window</item>
<item name="android:statusBarColor">@color/dodo_window</item>
<item name="android:navigationBarColor">@color/dodo_window</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar">false</item>
</style>
</resources>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The terminal renderer is a page served over plain HTTP on 127.0.0.1 by TerminalDataPlane, and the
binary protocol under it is a WebSocket on the same origin.
Android 9 (API 28) — this head's own minimum — turned cleartext HTTP off by default. Without this file
the WebView refuses to load the renderer and the terminal is simply blank, with the failure appearing as
a renderer that never attaches rather than as anything naming TLS. That is the same blank-terminal
symptom the desktop head documents for a missing WebView2 runtime, arrived at from the opposite
direction, so it is worth knowing which one you are looking at.
Scoped to loopback and nothing else. Setting cleartextTrafficPermitted at the base config would allow
plaintext to every host, including the DodoSSH server the sync client talks to — and that connection
carries bearer tokens. Loopback is exempt from the objection anyway: the bytes never leave the device,
and the socket is the same process talking to its own WebView.
-->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
</domain-config>
</network-security-config>
@@ -0,0 +1,128 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.Android.Views.LockedScreen"
x:DataType="vm:MainWindowViewModel"
Background="{StaticResource Canvas}">
<!--
Design 01 — LOCKED, and one of the five states docs/android-port.md marks as most likely to be lost at
360dp. Two things on it are load-bearing and neither is decoration:
the count of shells still connected, and the paragraph under it. Locking describes the keychain and not
this phone's access to the hosts, and the desktop's README says so at length; on a phone, where the
lock screen is most of what a user sees, saying it here is the only place it fits. The block is absent
rather than empty when nothing is connected — a card reading "0 shells" would be noise on every launch.
Everything is one column with generous vertical slack above and below the controls, because the
software keyboard takes roughly half the screen the moment the passphrase box is focused. The slack is
what it eats.
-->
<ScrollViewer VerticalScrollBarVisibility="Auto">
<Grid RowDefinitions="*,Auto,Auto" Margin="24,0">
<!-- Identity. The chip names the account so a phone with two profiles is not a guess. -->
<StackPanel Grid.Row="0" VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="10" Margin="0,48,0,36">
<Border Width="44" Height="44" BorderBrush="{StaticResource Accent}" BorderThickness="1"
HorizontalAlignment="Center">
<TextBlock Text="&gt;_" Foreground="{StaticResource Accent}" FontFamily="{StaticResource MonoFont}"
FontSize="16" FontWeight="SemiBold"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<TextBlock Text="DodoSSH" Foreground="{StaticResource Text}" FontFamily="{StaticResource MonoFont}"
FontSize="15" FontWeight="SemiBold" HorizontalAlignment="Center" />
<Border BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="3"
Padding="9,3" HorizontalAlignment="Center"
IsVisible="{Binding AccountName, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding AccountName}" Foreground="{StaticResource TextDim}"
FontFamily="{StaticResource MonoFont}" FontSize="10" FontWeight="Medium" />
</Border>
</StackPanel>
<StackPanel Grid.Row="1">
<!--
Enter unlocks, which matters more here than on the desktop: the software keyboard's action key is
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="•" Watermark="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"
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 Canvas}"
CornerRadius="6" FontFamily="{StaticResource MonoFont}" FontSize="12" FontWeight="SemiBold" />
<!--
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.
-->
<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}">
<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" />
</StackPanel>
</Button>
<TextBlock Text="works with no network — the keychain decrypts on this phone"
Foreground="{StaticResource TextFaint}" FontFamily="{StaticResource MonoFont}"
FontSize="10" TextAlignment="Center" TextWrapping="Wrap" Margin="0,14,0,0" />
<TextBlock Text="{Binding StatusMessage}" Foreground="{StaticResource TextDim}"
FontFamily="{StaticResource MonoFont}" FontSize="10" TextAlignment="Center"
TextWrapping="Wrap" Margin="0,8,0,0" />
<!--
◆ The disclosure. Absent when there is nothing to disclose; never a card reading zero.
-->
<Border IsVisible="{Binding HasLiveSessions}" Margin="0,22,0,0"
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
BorderThickness="1" CornerRadius="6" Padding="14,12">
<StackPanel Spacing="6">
<StackPanel Orientation="Horizontal" Spacing="8">
<Ellipse Width="6" Height="6" Fill="{StaticResource Accent}" VerticalAlignment="Center" />
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Warn}"
FontFamily="{StaticResource MonoFont}" FontSize="10" FontWeight="SemiBold"
TextWrapping="Wrap" />
</StackPanel>
<TextBlock Foreground="{StaticResource WarnText}" FontFamily="{StaticResource MonoFont}"
FontSize="10" TextWrapping="Wrap"
Text="Locked describes the keychain — not this phone's access to the hosts. Open sessions stay alive behind this screen." />
</StackPanel>
</Border>
</StackPanel>
<!--
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.
-->
<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"
Content="RESET THIS PHONE — forgot passphrase" />
</Grid>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,10 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>Design 01 — the unlock screen.</summary>
internal sealed partial class LockedScreen : UserControl
{
public LockedScreen() => AvaloniaXamlLoader.Load(this);
}
@@ -0,0 +1,38 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="using:DodoSSH.Client.Android.Views"
x:Class="DodoSSH.Client.Android.Views.PendingScreen"
x:DataType="views:PendingScreen"
Background="{StaticResource Canvas}">
<!--
The phone's counterpart of the desktop head's NotBuiltScreen, and it exists for the same reason: a
screen that is not built says so, in its own words, rather than being dropped from the shell or filled
with plausible-looking data. See README — nothing here is rendered with invented data.
Two properties rather than a shared string, because the copy is written per state. "No items" would be
the exact failure this control is here to avoid.
-->
<ScrollViewer>
<StackPanel VerticalAlignment="Center" Margin="24,48" Spacing="12">
<Border Width="44" Height="44" BorderBrush="{StaticResource BorderMid}" BorderThickness="1"
HorizontalAlignment="Left">
<TextBlock Text="&gt;_" Foreground="{StaticResource TextFaint}" FontFamily="{StaticResource MonoFont}"
FontSize="16" FontWeight="SemiBold"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<TextBlock Text="{Binding Heading}" Foreground="{StaticResource Text}"
FontFamily="{StaticResource MonoFont}" FontSize="14" FontWeight="SemiBold"
TextWrapping="Wrap" />
<TextBlock Text="{Binding Detail}" Foreground="{StaticResource TextDim}"
FontFamily="{StaticResource MonoFont}" FontSize="11" LineHeight="19"
TextWrapping="Wrap" />
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,43 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>
/// A state this head has not built, saying so in its own words.
/// </summary>
/// <remarks>
/// Styled properties rather than a view model: there is no state behind this control, and giving it one
/// would make it look like a screen that might one day have data.
/// </remarks>
internal sealed partial class PendingScreen : UserControl
{
public static readonly StyledProperty<string> HeadingProperty =
AvaloniaProperty.Register<PendingScreen, string>(nameof(Heading), string.Empty);
public static readonly StyledProperty<string> DetailProperty =
AvaloniaProperty.Register<PendingScreen, string>(nameof(Detail), string.Empty);
public PendingScreen()
{
AvaloniaXamlLoader.Load(this);
// Its own data context, so the two properties can be bound in XAML like anything else. Safe here
// and only here: this control deliberately shows nothing from the shell, so there is no inherited
// context worth keeping.
DataContext = this;
}
public string Heading
{
get => GetValue(HeadingProperty);
set => SetValue(HeadingProperty, value);
}
public string Detail
{
get => GetValue(DetailProperty);
set => SetValue(DetailProperty, value);
}
}
@@ -0,0 +1,58 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.Android.Views"
x:Class="DodoSSH.Client.Android.Views.PhoneShell"
x:DataType="vm:MainWindowViewModel"
Background="{StaticResource Canvas}">
<!--
The phone's single view, and the counterpart of the desktop head's MainWindow — except that this one
has no window, no nav rail, no titlebar and no status bar. It switches on ShellState and nothing else.
The states are the same six the desktop has, and they are the same six for a good reason: they are the
shell's state machine, which both heads share. What differs is only what each one draws.
Panels rather than a template selector, matching the desktop head: each screen's visibility is one
binding, and the whole tree is laid out once. There is no WebView occlusion problem to design around
here, which is the one structural simplification the phone gets for free — see docs/android-port.md §9
for what is still unverified about that on this platform.
-->
<Panel>
<views:LockedScreen IsVisible="{Binding IsLocked}" DataContext="{Binding}" />
<!--
The states this head has not built yet, named rather than hidden. The convention is the desktop
head's NotBuiltScreen and the reason is in README: nothing is rendered with invented data to fill a
screen, and a state that silently showed nothing would be indistinguishable from one that had
quietly broken.
Sign-in is the substantial one, and it is not merely unwritten — it needs a different redirect. See
docs/android-port.md §5: the loopback listener the desktop uses is the attack RFC 8252 §8.3 names on
a shared device, so this head needs an app link before it can honestly offer the flow at all.
-->
<views:PendingScreen IsVisible="{Binding IsStarting}"
Heading="OPENING THE KEYCHAIN"
Detail="Reading this phone's local cache to find out whether it is enrolled." />
<views:PendingScreen IsVisible="{Binding IsNeedingServer}"
Heading="SIGN-IN IS NOT BUILT HERE YET"
Detail="This phone has no profile, and signing in needs a redirect this head does not have. The desktop client's loopback listener is deliberately not reused: on a shared device any other app can bind a loopback port, which is the attack RFC 8252 §8.3 names. An app link is the next piece of work. Enroll on the desktop client and this phone will unlock against the same vault." />
<views:PendingScreen IsVisible="{Binding IsNeedingEnrollment}"
Heading="ENROLLMENT IS NOT BUILT HERE YET"
Detail="This account has no vault key. Choosing a passphrase — and writing down the recovery code that follows it — happens on the desktop client for now." />
<views:PendingScreen IsVisible="{Binding IsShowingRecoveryCode}"
Heading="RECOVERY CODE"
Detail="This state is reachable only after enrollment, which this head does not do yet. It is the one screen a user must never be able to click past, so it is left unbuilt rather than approximated." />
<views:PendingScreen IsVisible="{Binding IsUnlocked}"
Heading="UNLOCKED"
Detail="The vault is open. The host list, the keychain and the terminal are the next tranche of screens; the view models behind all three are already here and already driven by the desktop head." />
</Panel>
</UserControl>
@@ -0,0 +1,10 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>The phone's single view. The desktop head's MainWindow, without the window.</summary>
internal sealed partial class PhoneShell : UserControl
{
public PhoneShell() => AvaloniaXamlLoader.Load(this);
}
File diff suppressed because it is too large Load Diff
+8 -66
View File
@@ -12,73 +12,15 @@
<Application.Resources> <Application.Resources>
<!-- <!--
The palette, named for what a colour is for rather than for what it looks like. Every one of these The palette and the font stack, shared with the Android head. They moved into DodoSSH.Client.Shell
is from the design; the names are this codebase's, because "#0C0F0E" appearing in nine files is how a when there were two heads that had to agree on them; Theme/Palette.axaml carries the reasoning behind
surface ends up two shades off in the tenth. the names, the five near-black surfaces and the font substitution.
Five near-black surfaces rather than one, and the difference between them is real work: the window is
the darkest so the terminal reads as the lit thing, chrome sits one step up so the titlebar and status
bar frame it, and the sidebars sit between the two so a list does not look like part of either.
--> -->
<Color x:Key="CanvasColor">#0A0C0B</Color> <ResourceDictionary>
<SolidColorBrush x:Key="Canvas" Color="{StaticResource CanvasColor}" /> <ResourceDictionary.MergedDictionaries>
<SolidColorBrush x:Key="Chrome" Color="#0D100F" /> <ResourceInclude Source="avares://DodoSSH.Client.Shell/Theme/Palette.axaml" />
<SolidColorBrush x:Key="Sidebar" Color="#0C0F0E" /> </ResourceDictionary.MergedDictionaries>
<SolidColorBrush x:Key="Panel" Color="#0F1211" /> </ResourceDictionary>
<SolidColorBrush x:Key="Raised" Color="#111514" />
<SolidColorBrush x:Key="Field" Color="#121615" />
<!-- Row hover, and the heavier one the chrome's own buttons use. -->
<SolidColorBrush x:Key="Hover" Color="#141817" />
<SolidColorBrush x:Key="ChromeHover" Color="#1A1F1D" />
<!--
Three border weights, and they are not interchangeable. Strong separates one region of the window from
another, subtle separates rows inside one region, and mid is what a control draws around itself.
-->
<SolidColorBrush x:Key="Border" Color="#1E2422" />
<SolidColorBrush x:Key="BorderSubtle" Color="#171C1A" />
<SolidColorBrush x:Key="BorderMid" Color="#2A312E" />
<SolidColorBrush x:Key="BorderHover" Color="#3A423E" />
<SolidColorBrush x:Key="BorderFaint" Color="#232927" />
<!--
The text ramp. Three steps, used consistently: what you read, what you glance at, and what is there
only so its absence would be noticed. A fourth step would be one nobody could tell from its neighbours.
-->
<SolidColorBrush x:Key="Text" Color="#DCE3DF" />
<SolidColorBrush x:Key="TextDim" Color="#7E8A84" />
<SolidColorBrush x:Key="TextFaint" Color="#566059" />
<!--
The accent, and the three colours that are allowed to disagree with it. Green means live, connected or
yours; amber means a caveat worth reading; red means refused or destructive; blue is for the one thing
that is neither — a directory, a distinct scope — and is deliberately rare.
-->
<Color x:Key="AccentColor">#3CE88F</Color>
<SolidColorBrush x:Key="Accent" Color="{StaticResource AccentColor}" />
<SolidColorBrush x:Key="AccentSoft" Color="#3CE88F" Opacity="0.35" />
<SolidColorBrush x:Key="AccentWash" Color="#3CE88F" Opacity="0.06" />
<SolidColorBrush x:Key="Warn" Color="#E8B44C" />
<SolidColorBrush x:Key="WarnSoft" Color="#E8B44C" Opacity="0.35" />
<SolidColorBrush x:Key="WarnWash" Color="#E8B44C" Opacity="0.06" />
<SolidColorBrush x:Key="WarnText" Color="#B9A26B" />
<SolidColorBrush x:Key="Danger" Color="#E85D5D" />
<SolidColorBrush x:Key="DangerSoft" Color="#E85D5D" Opacity="0.3" />
<SolidColorBrush x:Key="DangerWash" Color="#E85D5D" Opacity="0.08" />
<SolidColorBrush x:Key="Info" Color="#5DA9E8" />
<!--
The design asks for IBM Plex Mono and IBM Plex Sans. Neither ships with this application and neither is
on a stock Windows install, so requesting them by name would render as whatever the font fallback chose
that day — which is worse than choosing deliberately. Inter is embedded by the host and is what the
window already draws with; the monospace stack is the one every other view here already names, so the
terminal's own font and the chrome's agree.
Named as resources rather than repeated, because the substitution is the sort of thing that gets
reversed later and should be reversible in one place. See docs/design-import-gaps.md.
-->
<FontFamily x:Key="MonoFont">ui-monospace,Cascadia Mono,Consolas,monospace</FontFamily>
</Application.Resources> </Application.Resources>
+2 -2
View File
@@ -1,8 +1,8 @@
using Avalonia; using Avalonia;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using DodoSSH.Client.App.Terminal; using DodoSSH.Client.Shell.Terminal;
using DodoSSH.Client.App.ViewModels; using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.App.Views; using DodoSSH.Client.App.Views;
using DodoSSH.Client.Auth; using DodoSSH.Client.Auth;
using DodoSSH.Client.Session; using DodoSSH.Client.Session;
@@ -25,6 +25,11 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<!--
The shell's view models, the terminal renderer's files and the palette, all shared with the Android
head. This project is now the desktop *views* and the desktop platform integration, and nothing else.
-->
<ProjectReference Include="../DodoSSH.Client.Shell/DodoSSH.Client.Shell.csproj" />
<ProjectReference Include="../DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" /> <ProjectReference Include="../DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" /> <ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
<ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" /> <ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
@@ -45,13 +50,4 @@
<InternalsVisibleTo Include="DodoSSH.Client.App.Layout.Tests" /> <InternalsVisibleTo Include="DodoSSH.Client.App.Layout.Tests" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<!--
The renderer's files, including the vendored xterm bundles. Embedded rather than copied to
disk so there is no separate deployment step and nothing on disk for another process to
tamper with between builds.
-->
<AvaloniaResource Include="WebAssets/**" />
</ItemGroup>
</Project> </Project>
@@ -1,6 +1,6 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.ViewModels" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.ConfirmDeleteCard" x:Class="DodoSSH.Client.App.Views.ConfirmDeleteCard"
x:DataType="vm:VaultViewModel"> x:DataType="vm:VaultViewModel">
@@ -1,6 +1,6 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.ViewModels" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views" xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.HostSidebar" x:Class="DodoSSH.Client.App.Views.HostSidebar"
x:DataType="vm:VaultViewModel"> x:DataType="vm:VaultViewModel">
@@ -1,6 +1,6 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using DodoSSH.Client.App.ViewModels; using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views; namespace DodoSSH.Client.App.Views;
@@ -1,7 +1,7 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="using:System.Collections.Generic" xmlns:sys="using:System.Collections.Generic"
xmlns:vm="using:DodoSSH.Client.App.ViewModels" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views" xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.MainWindow" x:Class="DodoSSH.Client.App.Views.MainWindow"
x:DataType="vm:MainWindowViewModel" x:DataType="vm:MainWindowViewModel"
@@ -1,7 +1,7 @@
using System.ComponentModel; using System.ComponentModel;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using DodoSSH.Client.App.ViewModels; using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views; namespace DodoSSH.Client.App.Views;
+1 -1
View File
@@ -1,6 +1,6 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.ViewModels" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.NavRail" x:Class="DodoSSH.Client.App.Views.NavRail"
x:DataType="vm:MainWindowViewModel"> x:DataType="vm:MainWindowViewModel">
@@ -1,6 +1,6 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.ViewModels" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views" xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.PreferencesScreen" x:Class="DodoSSH.Client.App.Views.PreferencesScreen"
x:DataType="vm:MainWindowViewModel"> x:DataType="vm:MainWindowViewModel">
@@ -1,6 +1,6 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.ViewModels" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.QuickConnect" x:Class="DodoSSH.Client.App.Views.QuickConnect"
x:DataType="vm:MainWindowViewModel"> x:DataType="vm:MainWindowViewModel">
@@ -3,7 +3,7 @@ using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Threading; using Avalonia.Threading;
using DodoSSH.Client.App.ViewModels; using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views; namespace DodoSSH.Client.App.Views;
@@ -1,6 +1,6 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.ViewModels" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.SignOutCard" x:Class="DodoSSH.Client.App.Views.SignOutCard"
x:DataType="vm:MainWindowViewModel"> x:DataType="vm:MainWindowViewModel">
+1 -1
View File
@@ -1,6 +1,6 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.ViewModels" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.StatusBar" x:Class="DodoSSH.Client.App.Views.StatusBar"
x:DataType="vm:MainWindowViewModel"> x:DataType="vm:MainWindowViewModel">
@@ -1,6 +1,6 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.ViewModels" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.TerminalTabs" x:Class="DodoSSH.Client.App.Views.TerminalTabs"
x:DataType="vm:MainWindowViewModel"> x:DataType="vm:MainWindowViewModel">
+1 -1
View File
@@ -1,6 +1,6 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.ViewModels" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.TitleBar" x:Class="DodoSSH.Client.App.Views.TitleBar"
x:DataType="vm:MainWindowViewModel"> x:DataType="vm:MainWindowViewModel">
@@ -1,6 +1,6 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.ViewModels" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.TransfersScreen" x:Class="DodoSSH.Client.App.Views.TransfersScreen"
x:DataType="vm:TransfersViewModel"> x:DataType="vm:TransfersViewModel">
@@ -1,6 +1,6 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using DodoSSH.Client.App.ViewModels; using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views; namespace DodoSSH.Client.App.Views;
@@ -1,6 +1,6 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.ViewModels" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.UnlockCard" x:Class="DodoSSH.Client.App.Views.UnlockCard"
x:DataType="vm:MainWindowViewModel"> x:DataType="vm:MainWindowViewModel">
@@ -1,6 +1,6 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.ViewModels" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views" xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.VaultScreen" x:Class="DodoSSH.Client.App.Views.VaultScreen"
x:DataType="vm:VaultViewModel"> x:DataType="vm:VaultViewModel">
+11
View File
@@ -360,6 +360,17 @@
"DodoSSH.Client.Sync": "[1.0.0, )" "DodoSSH.Client.Sync": "[1.0.0, )"
} }
}, },
"dodossh.client.shell": {
"type": "Project",
"dependencies": {
"Avalonia": "[12.1.1, )",
"CommunityToolkit.Mvvm": "[8.4.2, )",
"DodoSSH.Client.Session": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )",
"DodoSSH.Client.Transfer": "[1.0.0, )"
}
},
"dodossh.client.ssh": { "dodossh.client.ssh": {
"type": "Project", "type": "Project",
"dependencies": { "dependencies": {
@@ -0,0 +1,54 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!-- Same reasoning as the two heads: this code formats timestamps and host names for a person. -->
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<!--
Avalonia, but deliberately not Avalonia.Desktop and not a windowing backend. What is actually used
here is Dispatcher, the asset loader and a resource dictionary — none of which imply a window, which
is why this project can be referenced by a phone.
This is the one place the repository's "everything except App is free of Avalonia" rule bends, and it
bends on purpose: the rule existed so the SSH layer, the flow control and the OIDC flow could be
tested without a toolkit, and none of those are here. What is here is the shell's state machine,
which two heads have to agree on exactly.
-->
<PackageReference Include="Avalonia" />
<PackageReference Include="CommunityToolkit.Mvvm" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
<ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
<ProjectReference Include="../DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj" />
</ItemGroup>
<ItemGroup>
<!--
The view models are internal, as they were when they lived in the desktop head, and both heads plus
the two shell suites are let in explicitly. Making them public instead would turn every rename of a
command into a compatibility question about an assembly nobody consumes.
-->
<InternalsVisibleTo Include="DodoSSH.Client.App" />
<InternalsVisibleTo Include="DodoSSH.Client.Android" />
<InternalsVisibleTo Include="DodoSSH.Client.App.Tests" />
<InternalsVisibleTo Include="DodoSSH.Client.App.Layout.Tests" />
</ItemGroup>
<ItemGroup>
<!--
The renderer's files, including the vendored xterm bundles. They moved here from the desktop head
when the phone head needed the same terminal: two copies of a vendored bundle is how one of them ends
up a version behind. Embedded rather than copied to disk so there is no separate deployment step.
-->
<AvaloniaResource Include="WebAssets/**" />
<AvaloniaResource Include="Theme/**" />
</ItemGroup>
</Project>
@@ -1,7 +1,7 @@
using Avalonia.Platform; using Avalonia.Platform;
using DodoSSH.Client.Terminal; using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.App.Terminal; namespace DodoSSH.Client.Shell.Terminal;
/// <summary> /// <summary>
/// Serves the renderer's files from the assembly's embedded resources. /// Serves the renderer's files from the assembly's embedded resources.
@@ -13,7 +13,7 @@ namespace DodoSSH.Client.App.Terminal;
/// </remarks> /// </remarks>
internal sealed class AvaloniaTerminalAssetProvider : ITerminalAssetProvider internal sealed class AvaloniaTerminalAssetProvider : ITerminalAssetProvider
{ {
private const string ResourceRoot = "avares://DodoSSH.Client.App/WebAssets"; private const string ResourceRoot = "avares://DodoSSH.Client.Shell/WebAssets";
private static readonly (string Path, string File, string ContentType)[] Files = private static readonly (string Path, string File, string ContentType)[] Files =
[ [
@@ -0,0 +1,81 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
The palette, named for what a colour is for rather than for what it looks like. Every one of these is
from the design; the names are this codebase's, because "#0C0F0E" appearing in nine files is how a
surface ends up two shades off in the tenth.
It lives here, in the shared project, because there are now two heads drawing the same product. Two
copies of a palette is the same failure one file up: the phone's "connected" green drifting from the
desktop's is not a thing anybody would notice until a screenshot sat beside another screenshot.
Five near-black surfaces rather than one, and the difference between them is real work: the window is
the darkest so the terminal reads as the lit thing, chrome sits one step up so the header and status
bar frame it, and the lists sit between the two so a list does not look like part of either.
-->
<Color x:Key="CanvasColor">#0A0C0B</Color>
<SolidColorBrush x:Key="Canvas" Color="{StaticResource CanvasColor}" />
<SolidColorBrush x:Key="Chrome" Color="#0D100F" />
<SolidColorBrush x:Key="Sidebar" Color="#0C0F0E" />
<SolidColorBrush x:Key="Panel" Color="#0F1211" />
<SolidColorBrush x:Key="Raised" Color="#111514" />
<SolidColorBrush x:Key="Field" Color="#121615" />
<!-- Row hover, and the heavier one the chrome's own buttons use. On the phone these are press states. -->
<SolidColorBrush x:Key="Hover" Color="#141817" />
<SolidColorBrush x:Key="ChromeHover" Color="#1A1F1D" />
<!--
Three border weights, and they are not interchangeable. Strong separates one region from another,
subtle separates rows inside one region, and mid is what a control draws around itself.
-->
<SolidColorBrush x:Key="Border" Color="#1E2422" />
<SolidColorBrush x:Key="BorderSubtle" Color="#171C1A" />
<SolidColorBrush x:Key="BorderMid" Color="#2A312E" />
<SolidColorBrush x:Key="BorderHover" Color="#3A423E" />
<SolidColorBrush x:Key="BorderFaint" Color="#232927" />
<!--
The text ramp. Three steps, used consistently: what you read, what you glance at, and what is there
only so its absence would be noticed. A fourth step would be one nobody could tell from its neighbours.
-->
<SolidColorBrush x:Key="Text" Color="#DCE3DF" />
<SolidColorBrush x:Key="TextDim" Color="#7E8A84" />
<SolidColorBrush x:Key="TextFaint" Color="#566059" />
<SolidColorBrush x:Key="TextGhost" Color="#404743" />
<!--
The accent, and the three colours that are allowed to disagree with it. Green means live, connected or
yours; amber means a caveat worth reading; red means refused or destructive; blue is for the one thing
that is neither — a directory, a distinct scope — and is deliberately rare.
-->
<Color x:Key="AccentColor">#3CE88F</Color>
<SolidColorBrush x:Key="Accent" Color="{StaticResource AccentColor}" />
<SolidColorBrush x:Key="AccentSoft" Color="#3CE88F" Opacity="0.35" />
<SolidColorBrush x:Key="AccentWash" Color="#3CE88F" Opacity="0.06" />
<SolidColorBrush x:Key="Warn" Color="#E8B44C" />
<SolidColorBrush x:Key="WarnSoft" Color="#E8B44C" Opacity="0.35" />
<SolidColorBrush x:Key="WarnWash" Color="#E8B44C" Opacity="0.06" />
<SolidColorBrush x:Key="WarnText" Color="#B9A26B" />
<SolidColorBrush x:Key="Danger" Color="#E85D5D" />
<SolidColorBrush x:Key="DangerSoft" Color="#E85D5D" Opacity="0.3" />
<SolidColorBrush x:Key="DangerWash" Color="#E85D5D" Opacity="0.08" />
<SolidColorBrush x:Key="DangerText" Color="#D98A8A" />
<SolidColorBrush x:Key="Info" Color="#5DA9E8" />
<!--
The design asks for IBM Plex Mono and IBM Plex Sans. Neither ships with this application, and neither
is on a stock Windows install or a stock Android one, so requesting them by name would render as
whatever the font fallback chose that day — which is worse than choosing deliberately. Inter is
embedded by both heads and is what they already draw with.
The stack ends in the generic `monospace` rather than a Windows face, which is what makes it work on
both: Android has no Cascadia Mono or Consolas and resolves the generic name to its own mono face.
Named as a resource rather than repeated, because the substitution is the sort of thing that gets
reversed later and should be reversible in one place. See docs/design-import-gaps.md.
-->
<FontFamily x:Key="MonoFont">ui-monospace,Cascadia Mono,Consolas,monospace</FontFamily>
</ResourceDictionary>
@@ -12,7 +12,7 @@ using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal; using DodoSSH.Client.Terminal;
using DodoSSH.Crypto; using DodoSSH.Crypto;
namespace DodoSSH.Client.App.ViewModels; namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>Which of the shell's mutually exclusive screens is showing.</summary> /// <summary>Which of the shell's mutually exclusive screens is showing.</summary>
internal enum ShellState internal enum ShellState
@@ -1,6 +1,6 @@
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
namespace DodoSSH.Client.App.ViewModels; namespace DodoSSH.Client.Shell.ViewModels;
/// <summary> /// <summary>
/// One open terminal, as a tab. /// One open terminal, as a tab.
@@ -7,7 +7,7 @@ using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh; using DodoSSH.Client.Ssh;
using DodoSSH.Client.Transfer; using DodoSSH.Client.Transfer;
namespace DodoSSH.Client.App.ViewModels; namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>One segment of a path, as a button in a breadcrumb trail.</summary> /// <summary>One segment of a path, as a button in a breadcrumb trail.</summary>
/// <param name="Name">What the segment is called.</param> /// <param name="Name">What the segment is called.</param>
@@ -11,7 +11,7 @@ using DodoSSH.Client.Ssh;
using DodoSSH.Client.Sync; using DodoSSH.Client.Sync;
using DodoSSH.Client.Terminal; using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.App.ViewModels; namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>One host, as a row in the list.</summary> /// <summary>One host, as a row in the list.</summary>
/// <remarks> /// <remarks>
+344
View File
@@ -0,0 +1,344 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Avalonia": {
"type": "Direct",
"requested": "[12.1.1, )",
"resolved": "12.1.1",
"contentHash": "o8pZ1oE9AQ6gklpGM0lnOBp/JlVH0J/0mYszBf0GsSAcEnzHNCLM9NnrPZwKu4j2q9oNbVHYzzEPkszQeuqaKw==",
"dependencies": {
"Avalonia.BuildServices": "11.3.2",
"Avalonia.Remote.Protocol": "12.1.1",
"MicroCom.Runtime": "0.11.6"
}
},
"CommunityToolkit.Mvvm": {
"type": "Direct",
"requested": "[8.4.2, )",
"resolved": "8.4.2",
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
},
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.137, )",
"resolved": "3.0.137",
"contentHash": "48n8WsokyefLOZt2qGPpk+hjfF9rQRa7MGK+8hMS6sO22QuIDtBztfbXV7RtVfX7JKjUwDI9e06I8aeCc5XZzQ=="
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
"type": "Direct",
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"Avalonia.BuildServices": {
"type": "Transitive",
"resolved": "11.3.2",
"contentHash": "qHDToxto1e3hci5YqbG9n0Ty8mlp3zBUN5wT66wKqaDVzXyQ0do3EnRILd4Ke9jpvsktaPpgE0YjEk7hornryQ=="
},
"Avalonia.Remote.Protocol": {
"type": "Transitive",
"resolved": "12.1.1",
"contentHash": "0u77tnOwJnHtVLu+WBY7T56fN9W8n7++Uq9kHW6J+bfv5y13WZUMVS+PBzoBe32taYvz/oSomDGO1V41AHaFcQ=="
},
"MicroCom.Runtime": {
"type": "Transitive",
"resolved": "0.11.6",
"contentHash": "NdNWGDiZ6eS/Mf/9+QHR91cj1K7Hy+PX9yrHI/zM7xFYuj9IWT2uxtB6sCHjrnxAeLV9fut1R6zHDUGKX6f9lQ=="
},
"Microsoft.Data.Sqlite.Core": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "TPCs0ldm7AWqcKmp6f/Xr+14sat7hx4rHfRlS4RgCURBH2thEWbAKEyX7cCWr63zVJVOJIJZTg2cBiUXa8ys6g==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.EntityFrameworkCore.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA=="
},
"Microsoft.EntityFrameworkCore.Analyzers": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q=="
},
"Microsoft.EntityFrameworkCore.Sqlite.Core": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "YbVWMIouzwTKBiLms8boa7xeRT88wI14R1msv3XExFk9n0/sa8nU7MwDa1CKtfLGMJs7O7QWuS9/xhcQ72AD2A==",
"dependencies": {
"Microsoft.Data.Sqlite.Core": "10.0.10",
"Microsoft.EntityFrameworkCore.Relational": "10.0.10",
"Microsoft.Extensions.Caching.Memory": "10.0.10",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
"Microsoft.Extensions.DependencyModel": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10",
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.Extensions.Caching.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.10"
}
},
"Microsoft.Extensions.Caching.Memory": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==",
"dependencies": {
"Microsoft.Extensions.Caching.Abstractions": "10.0.10",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
"Microsoft.Extensions.Options": "10.0.10",
"Microsoft.Extensions.Primitives": "10.0.10"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.10"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA=="
},
"Microsoft.Extensions.DependencyModel": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA=="
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "10.0.10",
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
"Microsoft.Extensions.Options": "10.0.10"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
}
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
"Microsoft.Extensions.Primitives": "10.0.10"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ=="
},
"dodossh.client.api": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Auth": "[1.0.0, )",
"DodoSSH.Contracts": "[1.0.0, )",
"DodoSSH.Crypto": "[1.0.0, )"
}
},
"dodossh.client.auth": {
"type": "Project"
},
"dodossh.client.domain": {
"type": "Project"
},
"dodossh.client.session": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Api": "[1.0.0, )",
"DodoSSH.Client.Auth": "[1.0.0, )",
"DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Storage": "[1.0.0, )",
"DodoSSH.Client.Sync": "[1.0.0, )"
}
},
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
"SSH.NET": "[2025.1.0, )"
}
},
"dodossh.client.storage": {
"type": "Project",
"dependencies": {
"DodoSSH.Contracts": "[1.0.0, )",
"DodoSSH.Crypto": "[1.0.0, )",
"EFCore.NamingConventions": "[10.0.1, )",
"Microsoft.EntityFrameworkCore.Sqlite": "[10.0.10, )"
}
},
"dodossh.client.sync": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Api": "[1.0.0, )",
"DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Storage": "[1.0.0, )",
"DodoSSH.Contracts": "[1.0.0, )",
"DodoSSH.Crypto": "[1.0.0, )"
}
},
"dodossh.client.terminal": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Ssh": "[1.0.0, )"
}
},
"dodossh.client.transfer": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Ssh": "[1.0.0, )"
}
},
"dodossh.contracts": {
"type": "Project"
},
"dodossh.crypto": {
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )"
}
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"EFCore.NamingConventions": {
"type": "CentralTransitive",
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)",
"Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1"
}
},
"libsodium": {
"type": "CentralTransitive",
"requested": "[1.0.22, )",
"resolved": "1.0.22",
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
},
"Microsoft.EntityFrameworkCore": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==",
"dependencies": {
"Microsoft.EntityFrameworkCore.Abstractions": "10.0.10",
"Microsoft.EntityFrameworkCore.Analyzers": "10.0.10",
"Microsoft.Extensions.Caching.Memory": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10"
}
},
"Microsoft.EntityFrameworkCore.Relational": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "10.0.10",
"Microsoft.Extensions.Caching.Memory": "10.0.10",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10"
}
},
"Microsoft.EntityFrameworkCore.Sqlite": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "kzg9MuQNJvZQxAU+piSkEzc7/1tpW6n1nVSGGMObu2GgxLK8Nf+6fvZundaznTZ+O2KhfPZ8HFNCzMH3PWDUmA==",
"dependencies": {
"Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.10",
"Microsoft.Extensions.Caching.Memory": "10.0.10",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
"Microsoft.Extensions.DependencyModel": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10",
"SQLitePCLRaw.bundle_e_sqlite3": "2.1.11",
"SQLitePCLRaw.core": "2.1.11"
}
},
"NSec.Cryptography": {
"type": "CentralTransitive",
"requested": "[26.4.0, )",
"resolved": "26.4.0",
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
"dependencies": {
"libsodium": "[1.0.22, 1.0.23)"
}
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "CentralTransitive",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "mAgscpQMLw5/nfA1Q5oJVAT29yROUo1ifZGbbTpx/lwZpSxMUGoYbKfmvdm8oXER+RzxqBmmQzeBEVKfeHv2nw==",
"dependencies": {
"SQLitePCLRaw.lib.e_sqlite3": "2.1.12",
"SQLitePCLRaw.provider.e_sqlite3": "2.1.12"
}
},
"SQLitePCLRaw.core": {
"type": "CentralTransitive",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "ETpNw9DY3ckWLgRRAeCHj+GKOuPi61aeczkXhgHexUvqoZBAYg8RYESE2J7O1M7+o6QbdSEZwrw9bfqztUVWXg=="
},
"SQLitePCLRaw.lib.e_sqlite3": {
"type": "CentralTransitive",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w=="
},
"SQLitePCLRaw.provider.e_sqlite3": {
"type": "CentralTransitive",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "W3oH4XIfCzFrgUSDKHhN6N+dgzA5YHOR2VxX8GB6Qy7CyrJJgxPEG8NirgYWlPQC5P2jz2knSsexWu4tDUL33g==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.12"
}
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
}
}
}
@@ -4,7 +4,7 @@ using Avalonia.Headless;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Threading; using Avalonia.Threading;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using DodoSSH.Client.App.ViewModels; using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.App.Views; using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session; using DodoSSH.Client.Session;
using DodoSSH.Client.Session.Tests; using DodoSSH.Client.Session.Tests;
@@ -4,7 +4,7 @@ using Avalonia.Headless;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Threading; using Avalonia.Threading;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using DodoSSH.Client.App.ViewModels; using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.App.Views; using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session; using DodoSSH.Client.Session;
using DodoSSH.Client.Session.Tests; using DodoSSH.Client.Session.Tests;
@@ -486,6 +486,7 @@
"Avalonia.Themes.Fluent": "[12.1.1, )", "Avalonia.Themes.Fluent": "[12.1.1, )",
"CommunityToolkit.Mvvm": "[8.4.2, )", "CommunityToolkit.Mvvm": "[8.4.2, )",
"DodoSSH.Client.Session": "[1.0.0, )", "DodoSSH.Client.Session": "[1.0.0, )",
"DodoSSH.Client.Shell": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )", "DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )", "DodoSSH.Client.Terminal": "[1.0.0, )",
"DodoSSH.Client.Transfer": "[1.0.0, )" "DodoSSH.Client.Transfer": "[1.0.0, )"
@@ -508,6 +509,17 @@
"DodoSSH.Client.Sync": "[1.0.0, )" "DodoSSH.Client.Sync": "[1.0.0, )"
} }
}, },
"dodossh.client.shell": {
"type": "Project",
"dependencies": {
"Avalonia": "[12.1.1, )",
"CommunityToolkit.Mvvm": "[8.4.2, )",
"DodoSSH.Client.Session": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )",
"DodoSSH.Client.Transfer": "[1.0.0, )"
}
},
"dodossh.client.ssh": { "dodossh.client.ssh": {
"type": "Project", "type": "Project",
"dependencies": { "dependencies": {
@@ -1,4 +1,4 @@
using DodoSSH.Client.App.ViewModels; using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Auth; using DodoSSH.Client.Auth;
using DodoSSH.Client.Session; using DodoSSH.Client.Session;
@@ -475,6 +475,7 @@
"Avalonia.Themes.Fluent": "[12.1.1, )", "Avalonia.Themes.Fluent": "[12.1.1, )",
"CommunityToolkit.Mvvm": "[8.4.2, )", "CommunityToolkit.Mvvm": "[8.4.2, )",
"DodoSSH.Client.Session": "[1.0.0, )", "DodoSSH.Client.Session": "[1.0.0, )",
"DodoSSH.Client.Shell": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )", "DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )", "DodoSSH.Client.Terminal": "[1.0.0, )",
"DodoSSH.Client.Transfer": "[1.0.0, )" "DodoSSH.Client.Transfer": "[1.0.0, )"
@@ -497,6 +498,17 @@
"DodoSSH.Client.Sync": "[1.0.0, )" "DodoSSH.Client.Sync": "[1.0.0, )"
} }
}, },
"dodossh.client.shell": {
"type": "Project",
"dependencies": {
"Avalonia": "[12.1.1, )",
"CommunityToolkit.Mvvm": "[8.4.2, )",
"DodoSSH.Client.Session": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )",
"DodoSSH.Client.Transfer": "[1.0.0, )"
}
},
"dodossh.client.ssh": { "dodossh.client.ssh": {
"type": "Project", "type": "Project",
"dependencies": { "dependencies": {