Public Access
Merge branch 'claude/android-release'
This commit is contained in:
@@ -92,7 +92,10 @@ internal sealed partial class DodoSshApp : Application
|
||||
|
||||
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var paths = ClientPaths.Default;
|
||||
// ForChannel rather than Default, so a nightly keeps its cache, outbox and device key somewhere
|
||||
// the release build never opens. See ClientPaths.ForChannel for why sharing them is the failure
|
||||
// worth spending a directory on.
|
||||
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
|
||||
var caches = ClientCacheFactory.ForFile(paths.CacheFile);
|
||||
|
||||
// Known hosts live in the vault, so trust survives a restart and follows the user to every device.
|
||||
|
||||
@@ -42,6 +42,30 @@
|
||||
-->
|
||||
<ApplicationIcon>Assets/dodossh.ico</ApplicationIcon>
|
||||
|
||||
<!--
|
||||
============ WHICH CHANNEL THIS BUILD BELONGS TO ============
|
||||
|
||||
The same split the Android head has, for the same reason and with one difference worth stating up
|
||||
front: Android gets separation for free, because the platform refuses an update signed by a
|
||||
different key, so its two channels cannot replace one another whatever anybody does. Nothing
|
||||
refuses anything here. Velopack applies what its feed serves, so the separation has to be built:
|
||||
two pack ids, two Velopack channels, two feeds, and a profile directory each.
|
||||
|
||||
release — DodoSSH.Desktop, Velopack channel win, read from the newest non-prerelease release.
|
||||
Cut from a v* tag by scripts/release-windows.ps1, by a person. See ADR 0013 rule 3.
|
||||
nightly — DodoSSH.Desktop.Nightly, Velopack channel win-nightly, read from a prerelease release
|
||||
CI replaces on every push to main.
|
||||
|
||||
Default release, so an unqualified `dotnet build` is the real application and the nightly is the
|
||||
one you have to ask for.
|
||||
|
||||
What this property does *not* decide is the pack id or the title. Those are arguments to vpk and
|
||||
live where the packaging happens — in ci.yml for the nightly and in the release script for the
|
||||
release. Putting them here would suggest the build knows which package it will end up inside,
|
||||
and it does not.
|
||||
-->
|
||||
<DodoChannel Condition="'$(DodoChannel)' == ''">release</DodoChannel>
|
||||
|
||||
<!--
|
||||
False here, unlike every server project. The root Directory.Build.props sets it true because
|
||||
the API is container-hosted, UTC-only and has no business formatting anything for a human.
|
||||
@@ -60,6 +84,16 @@
|
||||
<AvaloniaResource Include="Assets/dodossh.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
Which channel this build belongs to, carried in the assembly the same way the Android head carries
|
||||
it. Metadata rather than a compile-time constant for the reason stated there: the updater needs the
|
||||
string rather than a branch, and a value baked into the assembly is one a crash report can be asked
|
||||
for. See DesktopChannel, which is the only thing that reads it.
|
||||
-->
|
||||
<AssemblyMetadata Include="DodoChannel" Value="$(DodoChannel)" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Two native symbol files, and they are the reason a self-contained publish weighed 227 MB.
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Which of the two desktop channels this build belongs to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The counterpart of the Android head's <c>DodoChannel</c> metadata, read the same way and for the same
|
||||
/// reasons. Three things depend on the answer and they are listed here rather than discovered one at a
|
||||
/// time: which Velopack channel the updater reads, whether that read considers prereleases, and which
|
||||
/// profile directory this copy keeps its cache and device key in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Android gets this separation from the platform and this head has to build it.</b> Two Android
|
||||
/// channels cannot replace one another because the installer refuses a package signed by a different key.
|
||||
/// Nothing refuses anything on Windows: Velopack applies what its feed serves, without verifying a
|
||||
/// signature. So the two channels are kept apart by construction here — a pack id each, so they install
|
||||
/// in different directories; a Velopack channel each, so neither ever reads the other's release index; and
|
||||
/// a profile directory each, so a nightly cannot migrate the schema of a cache the release build is using.
|
||||
/// See <c>docs/adr/0013-desktop-distribution-and-updates.md</c> rule 9.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Resolved once, at first use. The channel of a build does not change while it runs, and a value read
|
||||
/// per call site is one that eventually gets read differently in two places.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class DesktopChannel
|
||||
{
|
||||
/// <summary>The build this channel is not: the one a person cuts from a tag.</summary>
|
||||
internal const string Release = "release";
|
||||
|
||||
/// <summary>The build CI publishes from main, which installs beside the release one.</summary>
|
||||
internal const string Nightly = "nightly";
|
||||
|
||||
/// <summary>
|
||||
/// What this build says it is, defaulting to the release channel.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The csproj always writes the metadata, so the fallback covers a build that reached here some other
|
||||
/// way — an assembly loaded by a test host, a designer, a trimmed-down copy. <see cref="Release"/> is
|
||||
/// the safe answer for all of them: it is what the value was before there were two channels, and every
|
||||
/// consequence of being wrong about it is inert. A build that is not installed has no updater at all
|
||||
/// (see <c>UpdateChannels.ForThisMachine</c>), and the profile directory it names is the one every
|
||||
/// existing install already uses.
|
||||
/// </remarks>
|
||||
internal static string Name { get; } = Read();
|
||||
|
||||
/// <summary>Whether this build belongs to the nightly channel.</summary>
|
||||
internal static bool IsNightly => string.Equals(Name, Nightly, StringComparison.Ordinal);
|
||||
|
||||
private static string Read()
|
||||
{
|
||||
var declared = typeof(DesktopChannel).Assembly
|
||||
.GetCustomAttributes<AssemblyMetadataAttribute>()
|
||||
.FirstOrDefault(attribute => string.Equals(attribute.Key, "DodoChannel", StringComparison.Ordinal))
|
||||
?.Value;
|
||||
|
||||
// Only the two the csproj declares are honoured. An unrecognised value is a build made by
|
||||
// something nobody here wrote, and answering "release" to it is the same inert default as
|
||||
// answering it to no value at all — rather than pointing an updater at a feed named by a string
|
||||
// of unknown origin.
|
||||
return string.Equals(declared, Nightly, StringComparison.Ordinal) ? Nightly : Release;
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,25 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
/// </remarks>
|
||||
private const string ReleaseChannel = "win";
|
||||
|
||||
/// <summary>
|
||||
/// The nightly channel, which is a different name rather than the same one on a different tag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A contract with the <c>desktop nightly</c> job in <c>.github/workflows/ci.yml</c>, which passes
|
||||
/// this word to both <c>vpk pack</c> and <c>vpk upload</c>. The name reaches the wire: Velopack
|
||||
/// publishes its index as <c>releases.win-nightly.json</c> and looks for exactly that file, so a
|
||||
/// disagreement between the two sides is a channel that answers nothing, forever, without an error.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Two names rather than one name on two tags, and that is the part doing the work.</b> Both
|
||||
/// channels are published to the same repository, so a client that read the other's index could be
|
||||
/// offered a package built under a different pack id. Velopack would refuse it, but at the far end of
|
||||
/// a download somebody watched. A channel each means neither ever sees the other's releases at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private const string NightlyChannel = "win-nightly";
|
||||
|
||||
private readonly UpdateManager manager;
|
||||
|
||||
/// <summary>
|
||||
@@ -138,10 +157,34 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
/// </remarks>
|
||||
public string CurrentVersion => ClientVersion.Current;
|
||||
|
||||
internal static UpdateManager CreateManager() =>
|
||||
new(
|
||||
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: false),
|
||||
new UpdateOptions { ExplicitChannel = ReleaseChannel });
|
||||
/// <summary>
|
||||
/// The updater for this build's channel.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The prerelease flag is the half Velopack cannot work out for itself.</b> The channel could be
|
||||
/// left to the installed layout — Velopack records what a package was built with — but whether to
|
||||
/// consider prereleases is a property of the feed rather than of the install, and it decides more
|
||||
/// than it looks. The nightly is published as a prerelease deliberately: the Android head's release
|
||||
/// channel reads <c>releases/latest</c>, which skips prereleases, so a desktop nightly published as a
|
||||
/// stable release would become the newest release in this repository and the phone's release channel
|
||||
/// would start finding no Android manifest on it. One flag here keeps the two heads out of each
|
||||
/// other's way.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The release channel takes <c>false</c>, so it cannot see the nightly at all — which is the property
|
||||
/// that matters most, because that is the direction where a mistake would put an unsigned CI build on
|
||||
/// a machine somebody trusts with their credentials.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static UpdateManager CreateManager()
|
||||
{
|
||||
var nightly = DesktopChannel.IsNightly;
|
||||
|
||||
return new UpdateManager(
|
||||
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: nightly),
|
||||
new UpdateOptions { ExplicitChannel = nightly ? NightlyChannel : ReleaseChannel });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
using DodoSSH.Client.App.Platform;
|
||||
using DodoSSH.Client.Session;
|
||||
using Velopack;
|
||||
|
||||
@@ -69,7 +70,12 @@ internal static class Program
|
||||
return;
|
||||
}
|
||||
|
||||
var folder = Path.Combine(ClientPaths.Default.DataDirectory, "WebView2");
|
||||
// The same profile directory the rest of the application resolves, channel and all — a nightly
|
||||
// pointing WebView2 at the release build's profile would put two browser profiles in one folder
|
||||
// and hand the pair of them whichever process opened first.
|
||||
var folder = Path.Combine(
|
||||
ClientPaths.ForChannel(DesktopChannel.Name).DataDirectory,
|
||||
"WebView2");
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -37,7 +37,11 @@
|
||||
The product's name is the one string in this bar that is not machine-shaped, so v2 sets it in the
|
||||
sans face while the address, the account and the fingerprint beside it stay monospaced.
|
||||
-->
|
||||
<TextBlock Text="DodoSSH" FontSize="14" FontWeight="SemiBold"
|
||||
<!--
|
||||
Named, because a nightly says so here. See the code-behind: the release build is what this
|
||||
markup says and nothing changes for it.
|
||||
-->
|
||||
<TextBlock x:Name="ProductName" Text="DodoSSH" FontSize="14" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
<!--
|
||||
The design puts an organisation here — "dodotech / platform". There are no organisations: the
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using DodoSSH.Client.App.Platform;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
@@ -21,7 +22,24 @@ namespace DodoSSH.Client.App.Views;
|
||||
/// </remarks>
|
||||
internal sealed partial class TitleBar : UserControl
|
||||
{
|
||||
public TitleBar() => InitializeComponent();
|
||||
public TitleBar()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// ◆ A nightly says which one it is, in the one place a person is always looking at.
|
||||
//
|
||||
// The two channels install side by side on purpose, so both windows are on screen at once, both
|
||||
// are called DodoSSH, and both ask for a passphrase the same way. Everything else that
|
||||
// distinguishes them — the Start menu entry, the install directory, the version on the
|
||||
// preferences screen — is somewhere nobody is looking while typing into one of them.
|
||||
//
|
||||
// Set here rather than in the markup so the release build is exactly what the XAML says, and so
|
||||
// the layout harness, which hosts this control directly, measures the shipping string.
|
||||
if (DesktopChannel.IsNightly)
|
||||
{
|
||||
ProductName.Text = "DodoSSH Nightly";
|
||||
}
|
||||
}
|
||||
|
||||
private Window? Host => TopLevel.GetTopLevel(this) as Window;
|
||||
|
||||
|
||||
@@ -23,8 +23,60 @@ public sealed record ClientPaths(string DataDirectory)
|
||||
private const string WindowsFolderName = "DodoSSH";
|
||||
private const string UnixFolderName = "dodossh";
|
||||
|
||||
/// <summary>
|
||||
/// The channel whose profile is kept apart, named here because this type is the one that acts on it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The string itself is the desktop head's <c>DesktopChannel.Nightly</c>. It is repeated rather than
|
||||
/// referenced because the dependency runs the wrong way — this project is shared with the Android
|
||||
/// head, which must never acquire a desktop updater — and because a value that reaches an assembly as
|
||||
/// build metadata is a string by the time anybody here sees it.
|
||||
/// </remarks>
|
||||
private const string NightlyChannelName = "nightly";
|
||||
|
||||
/// <summary>
|
||||
/// What the nightly's directory is called: the release one, with this on the end.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A sibling rather than a subdirectory of the release profile, so that neither install's uninstaller
|
||||
/// or reset can reach the other's, and so a person looking in <c>%LOCALAPPDATA%</c> sees two things
|
||||
/// with two names rather than one thing with a surprise inside it.
|
||||
/// </remarks>
|
||||
private const string NightlySuffix = ".Nightly";
|
||||
|
||||
/// <summary>The conventional location for this platform.</summary>
|
||||
public static ClientPaths Default { get; } = new(ResolveDataDirectory());
|
||||
public static ClientPaths Default { get; } = new(ResolveDataDirectory(suffix: null));
|
||||
|
||||
/// <summary>
|
||||
/// Where a build on the given channel keeps its profile.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A nightly may not share a profile with the release build, and the reason is the cache rather
|
||||
/// than the secrets.</b> The schema is migrated on every launch, before unlock; a nightly carrying a
|
||||
/// migration the release build has not shipped yet would upgrade a database the release build then
|
||||
/// opens. Both are installed at once by design — that is the whole point of a channel that installs
|
||||
/// beside rather than over — so this is an ordinary Tuesday rather than a corner case. Two of them
|
||||
/// running at the same time on one SQLite file and one outbox is the second reason and would be
|
||||
/// enough on its own.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It costs a nightly its sign-in and its known hosts, which is the honest trade: a nightly is a
|
||||
/// second installation of the application, and treating it as one is what stops it damaging the first.
|
||||
/// The device key is per install too, so the deployment sees a new device — which is exactly what
|
||||
/// happened, and what the trust model expects to be told about.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only the nightly channel is answered specially. Anything else, including the release channel and
|
||||
/// anything unrecognised, gets <see cref="Default"/> — the directory every existing install already
|
||||
/// uses, which must not move for any reason.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="channel">The build channel, as the head's own metadata reports it.</param>
|
||||
public static ClientPaths ForChannel(string? channel) =>
|
||||
string.Equals(channel, NightlyChannelName, StringComparison.Ordinal)
|
||||
? new ClientPaths(ResolveDataDirectory(NightlySuffix))
|
||||
: Default;
|
||||
|
||||
/// <summary>The encrypted local cache.</summary>
|
||||
public string CacheFile => Path.Combine(DataDirectory, "cache.db");
|
||||
@@ -68,20 +120,25 @@ public sealed record ClientPaths(string DataDirectory)
|
||||
/// here is one line versus depending on whether the runtime happens to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static string ResolveDataDirectory()
|
||||
private static string ResolveDataDirectory(string? suffix)
|
||||
{
|
||||
// Appended to the folder name rather than added as a path segment, on every platform, so the two
|
||||
// profiles are siblings everywhere. The Unix name is lower-cased with the rest of its folder.
|
||||
var windows = WindowsFolderName + suffix;
|
||||
var unix = UnixFolderName + suffix?.ToLowerInvariant();
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
WindowsFolderName);
|
||||
windows);
|
||||
}
|
||||
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return Path.Combine(home, "Library", "Application Support", WindowsFolderName);
|
||||
return Path.Combine(home, "Library", "Application Support", windows);
|
||||
}
|
||||
|
||||
var xdgDataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
|
||||
@@ -90,6 +147,6 @@ public sealed record ClientPaths(string DataDirectory)
|
||||
? Path.Combine(home, ".local", "share")
|
||||
: xdgDataHome;
|
||||
|
||||
return Path.Combine(root, UnixFolderName);
|
||||
return Path.Combine(root, unix);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user