Public Access
237 lines
11 KiB
C#
237 lines
11 KiB
C#
using Avalonia;
|
|
using Avalonia.Media;
|
|
using DodoSSH.Client.App.Platform;
|
|
using DodoSSH.Client.Session;
|
|
using Velopack;
|
|
|
|
namespace DodoSSH.Client.App;
|
|
|
|
internal static class Program
|
|
{
|
|
/// <summary>
|
|
/// The <c>dodossh://</c> link this process was started with, once it has claimed the profile; null for an
|
|
/// ordinary launch.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A static handed across to <c>DodoSshApp</c> rather than a constructor argument, because Avalonia
|
|
/// constructs the application itself — and the designer does too, through <see cref="BuildAvaloniaApp"/>,
|
|
/// with no arguments to have.
|
|
/// </remarks>
|
|
internal static SpelunkLink? LaunchLink { get; private set; }
|
|
/// <summary>
|
|
/// Entry point.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <c>STAThread</c> is required, not decorative: WebView2 checks the apartment state and refuses
|
|
/// to initialise on an MTA thread. Without it the terminal is simply blank on Windows. It applies to
|
|
/// everything below, which is why the Velopack call lives inside this method rather than in an entry
|
|
/// point of its own.
|
|
/// </remarks>
|
|
[STAThread]
|
|
public static void Main(string[] args)
|
|
{
|
|
// First, before Avalonia is even configured.
|
|
//
|
|
// The installer, the updater and the uninstaller all re-run this executable with arguments that
|
|
// mean "do the install bookkeeping and stop". Run() is what notices, does it, and exits — so on
|
|
// those runs nothing below happens at all, and that is the point rather than a side effect:
|
|
// DodoSshApp.Compose opens the SQLite cache and starts the terminal workspace's listening socket,
|
|
// and a silent installer run that reached either would be a background process holding the cache
|
|
// file open during the very file operations the installer is performing.
|
|
//
|
|
// There are deliberately no OnFirstRun or OnAfterUpdate hooks that touch the profile. A hook process
|
|
// has no passphrase, so the cache is bytes it cannot read, and the one thing that would want doing
|
|
// after an update — a schema migration — already runs on every ordinary launch from
|
|
// MainWindowViewModel.StartAsync, before unlock and touching no encrypted content. The hooks it does
|
|
// have touch only the registry; see RunVelopack.
|
|
RunVelopack();
|
|
|
|
KeepTheWebViewProfileOutOfTheInstallDirectory();
|
|
|
|
/*
|
|
One instance per profile, and nothing above this line is affected by it: the installer's own runs
|
|
have already exited inside VelopackApp.Run, and the WebView2 environment variable is a property of
|
|
this process rather than of the application.
|
|
|
|
Claimed here rather than after Avalonia is configured, so that a second launch starts nothing — no
|
|
toolkit, no window, no splash flashing on screen before it thinks better of it. What it does
|
|
instead is ask the running instance to come forward, which is why this is not simply an exit.
|
|
|
|
The handle is held for as long as Main runs, which is as long as the message loop does. See
|
|
SingleInstance for why it is a file rather than a mutex, and for what the profile scoping buys.
|
|
*/
|
|
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
|
|
|
|
// Read before the claim, because a launch that carries a link and finds DodoSSH already running
|
|
// passes the link on rather than merely asking for the window.
|
|
var link = LinkFrom(args);
|
|
|
|
using var claim = SingleInstance.Claim(paths, link: link);
|
|
|
|
if (claim is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
LaunchLink = link;
|
|
|
|
// After the claim, so a second launch that only hands its link over writes nothing.
|
|
CrashLog.Install(paths);
|
|
|
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
|
}
|
|
|
|
/// <summary>Lets Velopack do its install bookkeeping, with this application's three hooks.</summary>
|
|
/// <remarks>
|
|
/// The hooks touch only the registry: they make this executable the handler for <c>dodossh://</c> links,
|
|
/// which is how Spelunk's "Open in DodoSSH" reaches it, and take that away again on uninstall. Velopack
|
|
/// runs them on Windows only, and offers them only there; the other desktops declare the scheme in their
|
|
/// packages instead — build/macos's Info.plist template and build/linux's desktop entry. See
|
|
/// <see cref="WindowsUrlScheme"/>.
|
|
/// </remarks>
|
|
private static void RunVelopack()
|
|
{
|
|
var velopack = VelopackApp.Build();
|
|
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
velopack = velopack
|
|
.OnAfterInstallFastCallback(_ => RegisterUrlScheme())
|
|
.OnAfterUpdateFastCallback(_ => RegisterUrlScheme())
|
|
.OnBeforeUninstallFastCallback(_ => UnregisterUrlScheme());
|
|
}
|
|
|
|
velopack.Run();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The <c>dodossh://</c> link among this launch's arguments, if there is one and it passes the checks.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A link that fails them is dropped and the launch goes ahead as an ordinary one: the user clicked
|
|
/// something that meant "open DodoSSH", and a window is still the right answer to that even when what
|
|
/// came with it cannot be trusted. See <see cref="SpelunkLink.TryParse"/> for what is checked.
|
|
/// </remarks>
|
|
private static SpelunkLink? LinkFrom(string[] args) =>
|
|
args.FirstOrDefault(SpelunkLink.LooksLikeOne) is { } candidate
|
|
&& SpelunkLink.TryParse(candidate, out var link)
|
|
? link
|
|
: null;
|
|
|
|
/// <summary>Makes this executable the handler for <c>dodossh://</c> links, where that is a registry key.</summary>
|
|
/// <remarks>
|
|
/// Failures are swallowed. This runs inside Velopack's install, and an installer that failed because a
|
|
/// convenience could not be set up would be the wrong trade — the application works without it, and the
|
|
/// only thing lost is Spelunk's button opening it.
|
|
/// </remarks>
|
|
private static void RegisterUrlScheme()
|
|
{
|
|
if (!OperatingSystem.IsWindows() || Environment.ProcessPath is not { } executable)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
WindowsUrlScheme.Register(executable);
|
|
}
|
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.Security.SecurityException)
|
|
{
|
|
// See the remarks.
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc cref="RegisterUrlScheme" path="/remarks" />
|
|
private static void UnregisterUrlScheme()
|
|
{
|
|
if (!OperatingSystem.IsWindows() || Environment.ProcessPath is not { } executable)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
WindowsUrlScheme.Unregister(executable);
|
|
}
|
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.Security.SecurityException)
|
|
{
|
|
// See the remarks on RegisterUrlScheme.
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Puts WebView2's user data folder beside the vault cache instead of beside the executable.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// WebView2 defaults this to a directory next to the host executable. Under a Velopack install that is
|
|
/// <c>%LOCALAPPDATA%\DodoSSH.Desktop\current\</c>, and <c>current\</c> is <em>replaced</em> by every
|
|
/// update — so the browser profile would be destroyed on each one, and the first connect afterwards
|
|
/// would pay a cold WebView2 start: a new user data directory and a fresh process tree, which is the
|
|
/// slow path <c>TerminalWorkspaceOptions.RendererTimeout</c>'s fifteen seconds was sized for. It would
|
|
/// land at the exact moment somebody is most ready to believe the update broke the terminal.
|
|
/// </para>
|
|
/// <para>
|
|
/// The profile directory is the right home because Velopack never touches it — the pack id is
|
|
/// deliberately not <c>DodoSSH</c>, so the install root and <c>ClientPaths.DataDirectory</c> are
|
|
/// siblings rather than the same folder. See docs/adr/0013-desktop-distribution-and-updates.md.
|
|
/// </para>
|
|
/// <para>
|
|
/// An environment variable rather than the control's own options, because it is read by the WebView2
|
|
/// loader before any of this application's UI exists, and because it needs no reference to whichever
|
|
/// WebView package the terminal happens to be hosted by.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static void KeepTheWebViewProfileOutOfTheInstallDirectory()
|
|
{
|
|
if (!OperatingSystem.IsWindows())
|
|
{
|
|
return;
|
|
}
|
|
|
|
// 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
|
|
{
|
|
Directory.CreateDirectory(folder);
|
|
|
|
Environment.SetEnvironmentVariable("WEBVIEW2_USER_DATA_FOLDER", folder);
|
|
}
|
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
|
{
|
|
// Left unset, which puts the profile back beside the executable. That is a slow first connect
|
|
// after each update, not a broken terminal, and refusing to start an SSH client over it would
|
|
// be the wrong trade.
|
|
}
|
|
}
|
|
|
|
/// <summary>Used by the designer as well as by <see cref="Main"/>.</summary>
|
|
/// <remarks>
|
|
/// <c>WithInterFont</c> still registers Inter, and it is still what the layout suite pins: see
|
|
/// HeadlessApp. What changed is which face answers when nothing more specific is asked for. The v5
|
|
/// design specifies Montserrat, and unlike the substitution this comment used to explain, Montserrat
|
|
/// now ships embedded in the shell project rather than being requested by a name the machine may not
|
|
/// have — see DodoSSH.Client.Shell/Assets/Fonts and its csproj entry. Inter stays registered and is
|
|
/// named as a fallback, so a glyph Montserrat does not cover still resolves to a font that travels
|
|
/// with the build rather than to whatever the platform's default sans happens to be.
|
|
/// </remarks>
|
|
public static AppBuilder BuildAvaloniaApp() =>
|
|
AppBuilder.Configure<DodoSshApp>()
|
|
.UsePlatformDetect()
|
|
.WithInterFont()
|
|
.With(new FontManagerOptions
|
|
{
|
|
DefaultFamilyName = "avares://DodoSSH.Client.Shell/Assets/Fonts#Montserrat",
|
|
FontFallbacks =
|
|
[
|
|
new FontFallback { FontFamily = new FontFamily("avares://Avalonia.Fonts.Inter/Assets#Inter") },
|
|
],
|
|
})
|
|
.LogToTrace();
|
|
}
|