Files
DodoSSH/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
T
jaap-janandClaude Opus 5.5 1426be8f63 Precompile the macOS and Linux publishes too
ReadyToRun went onto the win-x64 publish in the previous change and took the main window from 2.6 s to
about 1.4 s on its own. The cause it answers — every launch JIT-compiling Avalonia, the XAML and EF
Core's model building — is the same on every desktop, so the condition is now any RID rather than win-*.

Verified in the Alpine SDK container that reproduces CI: osx-arm64 and linux-x64 both cross-compile,
`vpk '[osx]' bundle` builds the .app with its Info.plist, and scripts/ci-linux-rpm.sh builds the RPM
with its provides and requires checks passing. Each publish lists exactly the same files with
ReadyToRun as without, because the precompiled code lives inside the managed assemblies — so the
per-dylib signing loop in release-macos.sh and the RPM's library scan have nothing new to see. macOS
runs it under the allow-jit entitlement the JIT already needed.

The cost is size, as on Windows: osx-arm64 from 141 MB to 192 MB, linux-x64 from 130 MB to 172 MB.

Not measured: the startup gain on either platform, which the csproj comment says rather than implies,
and anything about the signed and notarized macOS build, which only release-macos.sh on a Mac produces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:09:14 +02:00

221 lines
12 KiB
XML

<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!--
The project is named for its layer and the executable is named for the product. Users see the
executable — in Task Manager, in a shortcut's target, in the SmartScreen dialog an unsigned
installer raises — and DodoSSH.Client.App.exe reads as an implementation detail leaking out.
Safe to change now and not later: it becomes the main executable recorded in every Velopack
package, and changing it after a release means the updater looks for an executable that is no
longer there. Nothing else depends on the name — no avares://DodoSSH.Client.App URI exists, the
window icon is the assembly-relative /Assets/dodossh.ico, and WindowsDeviceKeyStore names its
CNG key with a literal.
-->
<AssemblyName>DodoSSH</AssemblyName>
<!--
No RuntimeIdentifiers here, and that is the considered answer rather than an omission.
Declaring win-x64 is the obvious way to let a self-contained publish restore, and it was tried:
it works, and it also writes a net10.0/win-x64 target into the lock file of every project this
one references transitively — which includes DodoSSH.Contracts and DodoSSH.Crypto, which the
server builds too. The API's Dockerfile then restores those projects with no RID and locked
mode, and fails NU1004 on a lock file that has grown a runtime identifier the server knows
nothing about. The desktop head's packaging would have broken the server's image build.
So the RID stays out of the committed state entirely, and the publish that needs one asks for
it unlocked — see scripts/release-windows.ps1 and the CI step that proves the same thing. The
locked restore that gates every ordinary build is untouched, which is the property worth
keeping. See docs/platform-flags.md.
-->
<!--
The icon on the executable itself — what Explorer, the Start menu and a pinned taskbar button
draw, all of which read it from the PE resource and never start the process. Window.Icon in
MainWindow.axaml is a separate thing that only exists once the application is running; both are
needed, and both point at this file.
-->
<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>
<!--
For the macOS keychain interop in Platform/, and for nothing else.
Set on this project rather than in Directory.Build.props deliberately. The frameworks that hold a
Secure Enclave key take CFDictionaries of raw pointers, so building one means pinning arrays and
taking their addresses — see MacDeviceKeyStore. Every other project here is managed code with no
business doing that, and a solution-wide flag would quietly permit it everywhere, including in the
crypto project where a stray pointer is the last thing anybody wants to have been allowed.
The alternative — GCHandle.Alloc with GCHandleType.Pinned — needs no flag and was considered. It
would replace each `fixed` with an allocate/free pair that has to be balanced by hand across the
early returns those methods are full of, which trades a compiler-checked scope for a manual one.
-->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!--
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.
This process does: it shows timestamps and host names to a person who expects their own
locale's conventions. Invariant globalization would quietly render every date in a format
that is correct for nobody.
-->
<InvariantGlobalization>false</InvariantGlobalization>
<!--
Precompiled, on every desktop publish, because a cold start here is mostly the JIT. The runtime
pack's own assemblies already ship precompiled; ours and every package's — Avalonia, Skia's
bindings, EF Core, SSH.NET — ship as IL, and before this every launch compiled the toolkit, the XAML
and EF's model building method by method before anything reached the screen.
Measured on a self-contained win-x64 publish, warm disk, median of six launches: the splash from
735 ms to 536 ms after the process starts, and the main window idle on its first screen from 2.6 s
to 1.4 s. The cost is size, because each method is carried as IL and as code: the publish grows from
146 MB to 175 MB and the Setup executable from 70 MB to 89 MB. That is the trade worth making for an
application opened many times a day and installed once — and it is a fifth of what dropping the
native symbols below saves, so the first install is still far smaller than it once was.
The numbers above are Windows's, and only Windows has been timed. macOS and Linux take the same
publish — osx-arm64 and linux-x64 are cross-compiled on the same Linux runner, and ReadyToRun code
lives inside the managed assemblies, so neither the per-dylib signing loop in release-macos.sh nor
the RPM's library scan sees a new file: both publishes list exactly the same files with it as
without. They grow the same way Windows's does, osx-arm64 from 141 MB to 192 MB and linux-x64 from
130 MB to 172 MB. On macOS it runs under the allow-jit entitlement the JIT already needs. Whether they gain as much is unmeasured; that they gain at all is the runtime's own
bet, since its framework assemblies ship precompiled for both.
Conditioned on the RID rather than set outright, so a plain `dotnet build`, `dotnet run` and every
test project referencing this one are untouched: ReadyToRun is a publish step and needs a RID to
target.
-->
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
</PropertyGroup>
<ItemGroup>
<!--
Named rather than globbed as Assets/**, because the folder also holds the script that draws the
icon and a build has no reason to carry a copy of it around inside the binary.
-->
<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.
Measured on a win-x64 publish: libSkiaSharp.pdb is 80.1 MB and libHarfBuzzSharp.pdb is 19.9 MB, so
the two of them are 100 MB of debug symbols for third-party native code nobody here will ever step
through. Dropping them takes the publish to about 127 MB, which is most of what a first install
costs somebody on a slow connection.
Our own symbols stay, and the distinction is the point rather than a compromise. All fifteen managed
PDBs together are 0.93 MB, and with them present an Exception.ToString() carries file names and line
numbers — which for a self-hosted product is the whole diagnostic channel, because the way a fault
gets reported is a user pasting a stack into an issue.
Named one by one instead of matched by a pattern. A rule like "drop every .pdb whose assembly is
native" would be shorter and would silently start dropping ours the day a managed library ships a
file this heuristic misreads, and a build that quietly stops carrying line numbers is not a thing
anybody notices until they need them.
-->
<Target Name="DropNativeSymbolsFromPublish" AfterTargets="ComputeResolvedFilesToPublishList">
<ItemGroup>
<ResolvedFileToPublish
Remove="@(ResolvedFileToPublish)"
Condition="'%(Filename)%(Extension)' == 'libSkiaSharp.pdb'
or '%(Filename)%(Extension)' == 'libHarfBuzzSharp.pdb'" />
</ItemGroup>
</Target>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Avalonia.Controls.WebView" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<!--
Here and in no other project. The Android head must never reference it — that head's
distribution is settled by ADR 0011 and has no updater — and DodoSSH.Client.Shell is shared
between the two heads, so the one file that names Velopack lives in Platform/ beside
WindowsDeviceKeyStore, which is the same shape of thing: a Windows-only implementation of an
interface declared in DodoSSH.Client.Session.
-->
<PackageReference Include="Velopack" />
<!--
Here for the same reason and with the same boundary as Velopack above: LinuxDeviceKeyStore is a
Linux-only implementation of an interface declared in DodoSSH.Client.Session, and it lives in
Platform/ beside the Windows and macOS stores. The Android head has its own keystore and must
never see this one.
-->
<PackageReference Include="Microsoft.TSS" />
</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.Import/DodoSSH.Client.Import.csproj" />
<ProjectReference Include="../DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
<ProjectReference Include="../DodoSSH.Client.ObjectStore/DodoSSH.Client.ObjectStore.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 plain CommunityToolkit.Mvvm objects and need no Avalonia to run, so the shell's
state machine is testable as ordinary code. That is the whole reason the sign-in step is a delegate.
-->
<InternalsVisibleTo Include="DodoSSH.Client.App.Tests" />
<!--
The layout harness needs the views and the application class themselves, not just the view models:
its whole job is to lay out the real XAML and measure it.
-->
<InternalsVisibleTo Include="DodoSSH.Client.App.Layout.Tests" />
</ItemGroup>
</Project>