From 69397a2f102df6967c4ffdeee1e8fd86a741fd62 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sun, 1 Nov 2026 20:45:20 +1100 Subject: [PATCH] Split DiffEngineTray's package from the Windows head src/DiffEngineTray was both the Windows tray and the DiffEngineTray package, which names a package for each RID and holds nothing else. Its build for no RID packed that list, and first built the Linux and Mac heads for each of their RIDs, so TrayHead.targets had a branch only one head took (TrayPointer). The head is now src/DiffEngineTray.Windows, named DiffEngineTray with DiffEngineTray as its root namespace, as the other two heads are, so its pictures and forms keep their resource names. src/DiffEngineTray is the package and nothing else: ToolPackageRuntimeIdentifiers, PackTrayHeads, and references to the three heads for build order only. The SDK packs a tool only from an Exe, and builds it though the package holds none of its output, so it has a Program.cs that ships nowhere. What the package is called and says about itself is TrayPackage.props, which both import. The seven packages have the same entries as before. The Linux and macOS ones are byte for byte the same; the Windows head's dll differs only in its embedded PDB, which names the moved source files. DiffEngineTray's nuspec no longer names a Windows Forms framework reference, which came from the Windows head and meant nothing for a package of no code. --- claude.md | 9 +- src/DiffEngine.slnx | 6 + .../IconPixelsTests.cs | 2 +- .../DiffEngineTray.Core.csproj | 2 +- src/DiffEngineTray.Core/TrayHead.targets | 69 +----- src/DiffEngineTray.Core/TrayPackage.props | 14 ++ .../DiffEngineTray.Tests.csproj | 2 +- .../DebugForm.cs | 0 .../DebugFormLauncher.cs | 0 .../DiffEngineTray.Windows.csproj | 50 ++++ .../Extensions.cs | 0 .../FilePurger.cs | 0 .../GlobalSuppressions.cs | 0 .../GlobalUsings.cs | 0 .../HotKey/KeyBindingIds.cs | 0 .../HotKey/KeyModifiers.cs | 0 .../HotKey/KeyName.cs | 0 .../HotKey/KeyRegister.cs | 0 .../Images/Images.cs | 0 .../Images/accept.png | Bin .../Images/acceptAll.png | Bin .../Images/active.ico | Bin .../Images/active.png | Bin .../Images/cogs.png | Bin .../Images/default.ico | Bin .../Images/default.png | Bin .../Images/delete.png | Bin .../Images/discard.png | Bin .../Images/exit.png | Bin .../Images/folder.png | Bin .../Images/link.png | Bin .../Images/vs.png | Bin .../InternalsVisibleTo.cs | 0 .../IssueQuestion.cs | 0 .../LockedFilesForm.cs | 0 .../LockedFilesHandler.cs | 0 .../MenuBuilder.cs | 0 src/DiffEngineTray.Windows/Program.cs | 230 +++++++++++++++++ .../Settings/HotKeyControl.Designer.cs | 0 .../Settings/HotKeyControl.cs | 0 .../Settings/HotKeyControl.resx | 0 .../Settings/OptionsForm.Designer.cs | 0 .../Settings/OptionsForm.cs | 0 .../Settings/OptionsForm.resx | 0 .../Settings/OptionsFormLauncher.cs | 0 .../Settings/SettingsValidator.cs | 0 .../Startup.cs | 0 src/DiffEngineTray/DiffEngineTray.csproj | 97 +++++--- src/DiffEngineTray/Program.cs | 233 +----------------- 49 files changed, 381 insertions(+), 333 deletions(-) create mode 100644 src/DiffEngineTray.Core/TrayPackage.props rename src/{DiffEngineTray => DiffEngineTray.Windows}/DebugForm.cs (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/DebugFormLauncher.cs (100%) create mode 100644 src/DiffEngineTray.Windows/DiffEngineTray.Windows.csproj rename src/{DiffEngineTray => DiffEngineTray.Windows}/Extensions.cs (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/FilePurger.cs (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/GlobalSuppressions.cs (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/GlobalUsings.cs (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/HotKey/KeyBindingIds.cs (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/HotKey/KeyModifiers.cs (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/HotKey/KeyName.cs (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/HotKey/KeyRegister.cs (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Images/Images.cs (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Images/accept.png (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Images/acceptAll.png (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Images/active.ico (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Images/active.png (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Images/cogs.png (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Images/default.ico (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Images/default.png (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Images/delete.png (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Images/discard.png (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Images/exit.png (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Images/folder.png (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Images/link.png (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Images/vs.png (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/InternalsVisibleTo.cs (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/IssueQuestion.cs (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/LockedFilesForm.cs (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/LockedFilesHandler.cs (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/MenuBuilder.cs (100%) create mode 100644 src/DiffEngineTray.Windows/Program.cs rename src/{DiffEngineTray => DiffEngineTray.Windows}/Settings/HotKeyControl.Designer.cs (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Settings/HotKeyControl.cs (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Settings/HotKeyControl.resx (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Settings/OptionsForm.Designer.cs (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Settings/OptionsForm.cs (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Settings/OptionsForm.resx (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Settings/OptionsFormLauncher.cs (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Settings/SettingsValidator.cs (100%) rename src/{DiffEngineTray => DiffEngineTray.Windows}/Startup.cs (100%) diff --git a/claude.md b/claude.md index 4547d1d43..5d513facb 100644 --- a/claude.md +++ b/claude.md @@ -678,7 +678,7 @@ the comment there about not caching "nothing staged" asks for. itself) had not. GLFW opens the X11 extension libraries and `libGL` itself at run time, so only `libX11` is left of them. -**DiffEngineTray (`src/DiffEngineTray/`):** +**DiffEngineTray (`src/DiffEngineTray.*`):** - Windows Forms tray application that handles pending file diffs, in two projects. `src/DiffEngineTray.Core` is everything that is not WinForms - `Tracker`, both inline hosts, `PiperServer`, the settings, `DebugReport`, `IssueLauncher`, `ProcessEx` (which reads a process's image and command line @@ -686,7 +686,8 @@ the comment there about not caching "nothing staged" asks for. `DiffToolLauncher` and `FileLockKiller` (the restart manager's, which compiles anywhere and runs only on Windows, Unix locks being advisory) - and builds on every platform, so a macOS or Linux head can be put on it. Showing a file in a file manager is the viewer's `RevealFile`, - which the core links. `src/DiffEngineTray` is the Windows head: + which the core links. `src/DiffEngineTray.Windows` is the Windows head, a `WinExe` named + `DiffEngineTray` as the other heads are: `Program`, the forms, the hot keys (`KeyRegister`, `KeyName`, which parses into WinForms' `Keys`), the icons and the Run key. The menu is the core's `TrayMenu`: PlatformHeads' `TrayMenuItem`s built from what the tracker holds, an action for each by id, and tips kept beside @@ -759,7 +760,9 @@ the comment there about not caching "nothing staged" asks for. and `dotnet tool install -g DiffEngineTray` fetches `DiffEngineTray.{rid}` beside it, so the command did not change. Each RID's package is its head built for that RID, with that RID's viewer at `viewer/{rid}` and the documents folder, with that RID's natives only, at `viewer/documents`. - The Windows head's build for no RID packs `DiffEngineTray` (`TrayPointer`), and its + `DiffEngineTray` is packed by `src/DiffEngineTray`, a project that is that package and nothing + else: a head's build for no RID packs nothing. The SDK packs a tool only from an `Exe`, and builds + it though the package holds none of its output, so it has a `Program.cs` that ships nowhere. Its `PackTrayHeads` first builds each RID's head by the MSBuild task with `RuntimeIdentifier` added and nothing else, as the SDK does for a tool's own RID list: their references are then the instances the solution already built, restore is the solution's (`RuntimeIdentifiers` is each diff --git a/src/DiffEngine.slnx b/src/DiffEngine.slnx index de4374c34..8df486453 100644 --- a/src/DiffEngine.slnx +++ b/src/DiffEngine.slnx @@ -80,10 +80,16 @@ + + + + + + diff --git a/src/DiffEngineTray.Core.Tests/IconPixelsTests.cs b/src/DiffEngineTray.Core.Tests/IconPixelsTests.cs index 60fe8899d..78900e229 100644 --- a/src/DiffEngineTray.Core.Tests/IconPixelsTests.cs +++ b/src/DiffEngineTray.Core.Tests/IconPixelsTests.cs @@ -87,5 +87,5 @@ static byte[] Ico(int size, byte[] bgra) } static string Image(string name, [CallerFilePath] string source = "") => - Path.Combine(Path.GetDirectoryName(source)!, "..", "DiffEngineTray", "Images", name); + Path.Combine(Path.GetDirectoryName(source)!, "..", "DiffEngineTray.Windows", "Images", name); } diff --git a/src/DiffEngineTray.Core/DiffEngineTray.Core.csproj b/src/DiffEngineTray.Core/DiffEngineTray.Core.csproj index c8d2d6c79..b92aa05fe 100644 --- a/src/DiffEngineTray.Core/DiffEngineTray.Core.csproj +++ b/src/DiffEngineTray.Core/DiffEngineTray.Core.csproj @@ -24,7 +24,7 @@ - diff --git a/src/DiffEngineTray.Core/TrayHead.targets b/src/DiffEngineTray.Core/TrayHead.targets index afe4d1818..58c646d16 100644 --- a/src/DiffEngineTray.Core/TrayHead.targets +++ b/src/DiffEngineTray.Core/TrayHead.targets @@ -11,21 +11,19 @@ viewers and six sets of document natives, at tens of MB each. A build of a head for one of its RIDs packs that RID's package, with that RID's viewer and - documents folder in it. The Windows head's own build, for no RID, packs DiffEngineTray, and - builds every RID's head first to pack the rest (TrayPointer, below). + documents folder in it. A head's build for no RID packs nothing. DiffEngineTray itself is the + project in src/DiffEngineTray, which builds every RID's head first to pack the rest. --> + + - true - DiffEngineTray - DiffEngineTray - A utility that runs in the notification area, or the menu bar on macOS, and handles the results of file differences. $(TrayRids) - + false true false - false + false - - win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64 - PackTrayHeads;$(GenerateNuspecDependsOn) - - - - - - - - - - <_TrayRidPackage Include="$(MSBuildThisFileDirectory)..\DiffEngineTray\DiffEngineTray.csproj" AdditionalProperties="RuntimeIdentifier=win-x64" /> - <_TrayRidPackage Include="$(MSBuildThisFileDirectory)..\DiffEngineTray\DiffEngineTray.csproj" AdditionalProperties="RuntimeIdentifier=win-arm64" /> - <_TrayRidPackage Include="$(MSBuildThisFileDirectory)..\DiffEngineTray.Linux\DiffEngineTray.Linux.csproj" AdditionalProperties="RuntimeIdentifier=linux-x64" /> - <_TrayRidPackage Include="$(MSBuildThisFileDirectory)..\DiffEngineTray.Linux\DiffEngineTray.Linux.csproj" AdditionalProperties="RuntimeIdentifier=linux-arm64" /> - <_TrayRidPackage Include="$(MSBuildThisFileDirectory)..\DiffEngineTray.Mac\DiffEngineTray.Mac.csproj" AdditionalProperties="RuntimeIdentifier=osx-x64" /> - <_TrayRidPackage Include="$(MSBuildThisFileDirectory)..\DiffEngineTray.Mac\DiffEngineTray.Mac.csproj" AdditionalProperties="RuntimeIdentifier=osx-arm64" /> - - - - - - diff --git a/src/DiffEngineTray.Core/TrayPackage.props b/src/DiffEngineTray.Core/TrayPackage.props new file mode 100644 index 000000000..6a2a60005 --- /dev/null +++ b/src/DiffEngineTray.Core/TrayPackage.props @@ -0,0 +1,14 @@ + + + + + true + DiffEngineTray + DiffEngineTray + A utility that runs in the notification area, or the menu bar on macOS, and handles the results of file differences. + + diff --git a/src/DiffEngineTray.Tests/DiffEngineTray.Tests.csproj b/src/DiffEngineTray.Tests/DiffEngineTray.Tests.csproj index b9edb65fe..f6ee91b95 100644 --- a/src/DiffEngineTray.Tests/DiffEngineTray.Tests.csproj +++ b/src/DiffEngineTray.Tests/DiffEngineTray.Tests.csproj @@ -23,7 +23,7 @@ - + + + WinExe + net10.0 + DiffEngineTray + DiffEngineTray + true + true + NETSDK1137;WFAC010;NU5119 + false + LatestMajor + + win-x64;win-arm64 + + + + Windows + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DiffEngineTray/Extensions.cs b/src/DiffEngineTray.Windows/Extensions.cs similarity index 100% rename from src/DiffEngineTray/Extensions.cs rename to src/DiffEngineTray.Windows/Extensions.cs diff --git a/src/DiffEngineTray/FilePurger.cs b/src/DiffEngineTray.Windows/FilePurger.cs similarity index 100% rename from src/DiffEngineTray/FilePurger.cs rename to src/DiffEngineTray.Windows/FilePurger.cs diff --git a/src/DiffEngineTray/GlobalSuppressions.cs b/src/DiffEngineTray.Windows/GlobalSuppressions.cs similarity index 100% rename from src/DiffEngineTray/GlobalSuppressions.cs rename to src/DiffEngineTray.Windows/GlobalSuppressions.cs diff --git a/src/DiffEngineTray/GlobalUsings.cs b/src/DiffEngineTray.Windows/GlobalUsings.cs similarity index 100% rename from src/DiffEngineTray/GlobalUsings.cs rename to src/DiffEngineTray.Windows/GlobalUsings.cs diff --git a/src/DiffEngineTray/HotKey/KeyBindingIds.cs b/src/DiffEngineTray.Windows/HotKey/KeyBindingIds.cs similarity index 100% rename from src/DiffEngineTray/HotKey/KeyBindingIds.cs rename to src/DiffEngineTray.Windows/HotKey/KeyBindingIds.cs diff --git a/src/DiffEngineTray/HotKey/KeyModifiers.cs b/src/DiffEngineTray.Windows/HotKey/KeyModifiers.cs similarity index 100% rename from src/DiffEngineTray/HotKey/KeyModifiers.cs rename to src/DiffEngineTray.Windows/HotKey/KeyModifiers.cs diff --git a/src/DiffEngineTray/HotKey/KeyName.cs b/src/DiffEngineTray.Windows/HotKey/KeyName.cs similarity index 100% rename from src/DiffEngineTray/HotKey/KeyName.cs rename to src/DiffEngineTray.Windows/HotKey/KeyName.cs diff --git a/src/DiffEngineTray/HotKey/KeyRegister.cs b/src/DiffEngineTray.Windows/HotKey/KeyRegister.cs similarity index 100% rename from src/DiffEngineTray/HotKey/KeyRegister.cs rename to src/DiffEngineTray.Windows/HotKey/KeyRegister.cs diff --git a/src/DiffEngineTray/Images/Images.cs b/src/DiffEngineTray.Windows/Images/Images.cs similarity index 100% rename from src/DiffEngineTray/Images/Images.cs rename to src/DiffEngineTray.Windows/Images/Images.cs diff --git a/src/DiffEngineTray/Images/accept.png b/src/DiffEngineTray.Windows/Images/accept.png similarity index 100% rename from src/DiffEngineTray/Images/accept.png rename to src/DiffEngineTray.Windows/Images/accept.png diff --git a/src/DiffEngineTray/Images/acceptAll.png b/src/DiffEngineTray.Windows/Images/acceptAll.png similarity index 100% rename from src/DiffEngineTray/Images/acceptAll.png rename to src/DiffEngineTray.Windows/Images/acceptAll.png diff --git a/src/DiffEngineTray/Images/active.ico b/src/DiffEngineTray.Windows/Images/active.ico similarity index 100% rename from src/DiffEngineTray/Images/active.ico rename to src/DiffEngineTray.Windows/Images/active.ico diff --git a/src/DiffEngineTray/Images/active.png b/src/DiffEngineTray.Windows/Images/active.png similarity index 100% rename from src/DiffEngineTray/Images/active.png rename to src/DiffEngineTray.Windows/Images/active.png diff --git a/src/DiffEngineTray/Images/cogs.png b/src/DiffEngineTray.Windows/Images/cogs.png similarity index 100% rename from src/DiffEngineTray/Images/cogs.png rename to src/DiffEngineTray.Windows/Images/cogs.png diff --git a/src/DiffEngineTray/Images/default.ico b/src/DiffEngineTray.Windows/Images/default.ico similarity index 100% rename from src/DiffEngineTray/Images/default.ico rename to src/DiffEngineTray.Windows/Images/default.ico diff --git a/src/DiffEngineTray/Images/default.png b/src/DiffEngineTray.Windows/Images/default.png similarity index 100% rename from src/DiffEngineTray/Images/default.png rename to src/DiffEngineTray.Windows/Images/default.png diff --git a/src/DiffEngineTray/Images/delete.png b/src/DiffEngineTray.Windows/Images/delete.png similarity index 100% rename from src/DiffEngineTray/Images/delete.png rename to src/DiffEngineTray.Windows/Images/delete.png diff --git a/src/DiffEngineTray/Images/discard.png b/src/DiffEngineTray.Windows/Images/discard.png similarity index 100% rename from src/DiffEngineTray/Images/discard.png rename to src/DiffEngineTray.Windows/Images/discard.png diff --git a/src/DiffEngineTray/Images/exit.png b/src/DiffEngineTray.Windows/Images/exit.png similarity index 100% rename from src/DiffEngineTray/Images/exit.png rename to src/DiffEngineTray.Windows/Images/exit.png diff --git a/src/DiffEngineTray/Images/folder.png b/src/DiffEngineTray.Windows/Images/folder.png similarity index 100% rename from src/DiffEngineTray/Images/folder.png rename to src/DiffEngineTray.Windows/Images/folder.png diff --git a/src/DiffEngineTray/Images/link.png b/src/DiffEngineTray.Windows/Images/link.png similarity index 100% rename from src/DiffEngineTray/Images/link.png rename to src/DiffEngineTray.Windows/Images/link.png diff --git a/src/DiffEngineTray/Images/vs.png b/src/DiffEngineTray.Windows/Images/vs.png similarity index 100% rename from src/DiffEngineTray/Images/vs.png rename to src/DiffEngineTray.Windows/Images/vs.png diff --git a/src/DiffEngineTray/InternalsVisibleTo.cs b/src/DiffEngineTray.Windows/InternalsVisibleTo.cs similarity index 100% rename from src/DiffEngineTray/InternalsVisibleTo.cs rename to src/DiffEngineTray.Windows/InternalsVisibleTo.cs diff --git a/src/DiffEngineTray/IssueQuestion.cs b/src/DiffEngineTray.Windows/IssueQuestion.cs similarity index 100% rename from src/DiffEngineTray/IssueQuestion.cs rename to src/DiffEngineTray.Windows/IssueQuestion.cs diff --git a/src/DiffEngineTray/LockedFilesForm.cs b/src/DiffEngineTray.Windows/LockedFilesForm.cs similarity index 100% rename from src/DiffEngineTray/LockedFilesForm.cs rename to src/DiffEngineTray.Windows/LockedFilesForm.cs diff --git a/src/DiffEngineTray/LockedFilesHandler.cs b/src/DiffEngineTray.Windows/LockedFilesHandler.cs similarity index 100% rename from src/DiffEngineTray/LockedFilesHandler.cs rename to src/DiffEngineTray.Windows/LockedFilesHandler.cs diff --git a/src/DiffEngineTray/MenuBuilder.cs b/src/DiffEngineTray.Windows/MenuBuilder.cs similarity index 100% rename from src/DiffEngineTray/MenuBuilder.cs rename to src/DiffEngineTray.Windows/MenuBuilder.cs diff --git a/src/DiffEngineTray.Windows/Program.cs b/src/DiffEngineTray.Windows/Program.cs new file mode 100644 index 000000000..92c7068ed --- /dev/null +++ b/src/DiffEngineTray.Windows/Program.cs @@ -0,0 +1,230 @@ +static class Program +{ + /// + /// Synchronous, so that the attribute is on the method the runtime starts. For an + /// async Task Main that is a method the compiler writes, which carries none, and the + /// thread every window here lives on came up MTA: Clipboard.SetText throws ThreadStateException + /// there, so "Copy" in the debug view never copied anything. + /// + /// Blocking on is what that compiler written method did as well. Nothing + /// in it awaits until Application.Run() has returned, by which time WinForms has taken + /// its synchronization context back off this thread, so what follows continues on the pool + /// rather than waiting on a loop that is no longer pumping. + /// + /// + [STAThread] + static void Main() + { + IssueLauncher.Declined = IssueQuestion.Declined; + TrayViewerDirectory.Register(); + Logging.Init(); + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.SetHighDpiMode(HighDpiMode.SystemAware); + + try + { + Inner().GetAwaiter().GetResult(); + } + catch (Exception exception) + { + Log.Logger.Fatal(exception, "Failed at startup"); + throw; + } + } + + static async Task Inner() + { + var tokenSource = new CancelSource(); + var cancel = tokenSource.Token; + using var mutex = new Mutex(true, "DiffEngine", out var createdNew); + if (!createdNew) + { + Log.Information("Mutex already exists. Exiting."); + return; + } + + try + { + TrayVersionFile.Write(VersionReader.VersionString); + } + catch (Exception exception) + { + // The marker only gates inline snapshot payloads; the tray must still start + Log.Error(exception, "Failed to write the tray version marker"); + } + + // Before the settings file rather than after it, because nothing between here and the + // first use of a setting needs one, and until this line the user is looking at an empty + // notification area. Still after the mutex, so a second instance exits without ever + // showing an icon. + // The tips are those of the menus last shown, which the turns below hold, and are asked + // for only as a menu is built, by when there are turns to ask + TrayTurns? turns = null; + using var tray = NotifyIconTray.Open(MenuBuilder.Options(_ => turns?.Tooltip(_)), out var trayError) ?? + throw new InvalidOperationException($"Could not make the tray icon. {trayError}"); + // A picture now rather than at the first turn, which waits on everything below + tray.Apply(new(TrayIcon.Default, "DiffEngineTray", [])); + + void Warn(string message) => + tray.Notify(new("DiffEngineTray", message, Kind: TrayNotificationKind.Warning)); + + if (TrayUpdate.Report() is { } report) + { + // A failure as a warning, as every balloon of this tray's is + tray.Notify(report.Kind == TrayNotificationKind.Info ? report : report with {Kind = TrayNotificationKind.Warning}); + } + + var settings = GetSettings(); + if (settings == null) + { + return; + } + + LockedFilesPolicy.AlwaysKill = settings.AlwaysKillLockingProcesses; + + // Ownership of the inline queue is decided here, once, by whether the bind succeeds, and + // never transfers. Usually the tray wins, because it starts at login. A viewer that was + // already running keeps the queue for as long as it lives, and this tray drives it + // remotely for the rest of its own life rather than trying to take over mid flight. + await using var owned = OwnedInlineHost.TryOwn(Warn); + if (owned is null) + { + Log.Information("A viewer owns the inline queue. Driving it remotely."); + } + + var icon = TrayIcon.Default; + await using var tracker = new Tracker( + active: () => icon = TrayIcon.Active, + inactive: () => icon = TrayIcon.Default, + lockedFilesResolver: LockedFilesHandler.Resolve, + acceptFailed: move => Warn( + $"Could not accept '{move.Name}': the file move keeps failing. The move is still pending, so accept can be retried."), + inlineFailed: Warn, + inline: owned, + scanFailing: Warn); + + // Owning the queue means knowing the moment it changes, rather than finding out on the + // next two second scan. Wired before serving starts, so the first patch to arrive counts. + if (owned is not null) + { + owned.Changed = tracker.Refresh; + owned.TrackedFiles = tracker; + owned.Start(); + } + + // A logoff or a shutdown does not come back through Application.Run(), so nothing the + // unwind below performs happens for one + using var sessionEnd = new SessionEndWindow( + TraySessionEnd.For(owned, TrayVersionFile.Delete), + _ => Log.Error(_, "Failed while the session was ending")); + + // Not a using. Anything throwing between here and the await below would dispose a task + // that is still running, and Task.Dispose throws for one that has not completed - which + // would replace whatever actually went wrong with an InvalidOperationException. A task + // needs no disposal anyway; cancelling it is what ends it + var listener = PiperServer.TryBind(out var bindError); + if (listener is null) + { + Log.Error(bindError, "Could not listen on port {Port}", PiperClient.Port); + Warn($"Could not listen on port {PiperClient.Port}, so moves and deletes from test runs will not reach the tray. {bindError!.Message}"); + } + + var task = listener is null ? Task.CompletedTask : PiperServer.Serve(listener, tracker, cancel); + + using var keyRegister = new KeyRegister(tray.Handle); + ReBindKeys(settings, keyRegister, tracker, Warn); + + turns = new( + tray, + tracker, + new( + Exit: Application.Exit, + Options: async () => await OptionsFormLauncher.Launch(keyRegister, tracker), + DebugView: async () => await DebugFormLauncher.Launch(tracker), + Purge: FilePurger.Launch, + OpenDiffTool: DiffToolLauncher.Launch), + () => icon); + + // The tray's events are raised on this thread as Application.Run pumps it, and what they + // chose is done here too, a turn at a time, as the other heads' loops do + using var timer = new System.Windows.Forms.Timer + { + Interval = (int) TrayLoop.Turn.TotalMilliseconds + }; + timer.Tick += (_, _) => turns.Turn(); + timer.Start(); + + try + { + Application.Run(); + } + finally + { + TrayVersionFile.Delete(); + } + + await tokenSource.CancelAsync(); + await task; + } + + internal static void ReBindKeys(Settings settings, KeyRegister keyRegister, Tracker tracker, Action? warn = null) + { + foreach (var binding in BuildKeyBindings(settings, tracker)) + { + var hotKey = binding.HotKey; + if (keyRegister.TryAddBinding( + binding.Id, + hotKey.Shift, + hotKey.Control, + hotKey.Alt, + hotKey.Key, + binding.Action)) + { + continue; + } + + // Said out loud, because the alternative is a hot key that quietly does nothing. Only + // settings.json can produce a key name the Options form cannot, so only a hand edit + // reaches the first half of this + warn?.Invoke( + $"Could not bind the hot key '{hotKey.Key}'. It is either not a key name, or already registered by another application."); + } + } + + // Each configured hot key must map to a distinct KeyBindingIds value. + // Reusing an id causes KeyRegister.TryAddBinding to unregister and overwrite the earlier binding. + internal static IEnumerable BuildKeyBindings(Settings settings, Tracker tracker) + { + if (settings.DiscardAllHotKey is { } discardAll) + { + yield return new(KeyBindingIds.DiscardAll, discardAll, () => tracker.Clear()); + } + + if (settings.AcceptAllHotKey is { } acceptAll) + { + yield return new(KeyBindingIds.AcceptAll, acceptAll, () => tracker.AcceptAll()); + } + + if (settings.AcceptOpenHotKey is { } acceptOpen) + { + yield return new(KeyBindingIds.AcceptOpen, acceptOpen, () => tracker.AcceptOpen()); + } + } + + internal record KeyBinding(int Id, HotKey HotKey, Action Action); + + static Settings? GetSettings() + { + try + { + return SettingsHelper.Read(); + } + catch (Exception exception) + { + Log.Fatal(exception, "Cannot start. Failed to read settings: {FilePath}", SettingsHelper.FilePath); + IssueLauncher.LaunchForException($"Cannot start. Failed to read settings: {SettingsHelper.FilePath}", exception); + return null; + } + } +} \ No newline at end of file diff --git a/src/DiffEngineTray/Settings/HotKeyControl.Designer.cs b/src/DiffEngineTray.Windows/Settings/HotKeyControl.Designer.cs similarity index 100% rename from src/DiffEngineTray/Settings/HotKeyControl.Designer.cs rename to src/DiffEngineTray.Windows/Settings/HotKeyControl.Designer.cs diff --git a/src/DiffEngineTray/Settings/HotKeyControl.cs b/src/DiffEngineTray.Windows/Settings/HotKeyControl.cs similarity index 100% rename from src/DiffEngineTray/Settings/HotKeyControl.cs rename to src/DiffEngineTray.Windows/Settings/HotKeyControl.cs diff --git a/src/DiffEngineTray/Settings/HotKeyControl.resx b/src/DiffEngineTray.Windows/Settings/HotKeyControl.resx similarity index 100% rename from src/DiffEngineTray/Settings/HotKeyControl.resx rename to src/DiffEngineTray.Windows/Settings/HotKeyControl.resx diff --git a/src/DiffEngineTray/Settings/OptionsForm.Designer.cs b/src/DiffEngineTray.Windows/Settings/OptionsForm.Designer.cs similarity index 100% rename from src/DiffEngineTray/Settings/OptionsForm.Designer.cs rename to src/DiffEngineTray.Windows/Settings/OptionsForm.Designer.cs diff --git a/src/DiffEngineTray/Settings/OptionsForm.cs b/src/DiffEngineTray.Windows/Settings/OptionsForm.cs similarity index 100% rename from src/DiffEngineTray/Settings/OptionsForm.cs rename to src/DiffEngineTray.Windows/Settings/OptionsForm.cs diff --git a/src/DiffEngineTray/Settings/OptionsForm.resx b/src/DiffEngineTray.Windows/Settings/OptionsForm.resx similarity index 100% rename from src/DiffEngineTray/Settings/OptionsForm.resx rename to src/DiffEngineTray.Windows/Settings/OptionsForm.resx diff --git a/src/DiffEngineTray/Settings/OptionsFormLauncher.cs b/src/DiffEngineTray.Windows/Settings/OptionsFormLauncher.cs similarity index 100% rename from src/DiffEngineTray/Settings/OptionsFormLauncher.cs rename to src/DiffEngineTray.Windows/Settings/OptionsFormLauncher.cs diff --git a/src/DiffEngineTray/Settings/SettingsValidator.cs b/src/DiffEngineTray.Windows/Settings/SettingsValidator.cs similarity index 100% rename from src/DiffEngineTray/Settings/SettingsValidator.cs rename to src/DiffEngineTray.Windows/Settings/SettingsValidator.cs diff --git a/src/DiffEngineTray/Startup.cs b/src/DiffEngineTray.Windows/Startup.cs similarity index 100% rename from src/DiffEngineTray/Startup.cs rename to src/DiffEngineTray.Windows/Startup.cs diff --git a/src/DiffEngineTray/DiffEngineTray.csproj b/src/DiffEngineTray/DiffEngineTray.csproj index 9c64ab8f2..3831aa22b 100644 --- a/src/DiffEngineTray/DiffEngineTray.csproj +++ b/src/DiffEngineTray/DiffEngineTray.csproj @@ -1,46 +1,73 @@ - + + - WinExe + + Exe net10.0 - true - true - NETSDK1137;WFAC010;NU5119 + DiffEngineTray false - LatestMajor - - win-x64;win-arm64 - - true + win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64 + + false + PackTrayHeads;$(GenerateNuspecDependsOn) - - - Windows - - - - - - - + + + - - - - - - - - - - + + + - - - - + + <_TrayRidPackage Include="..\DiffEngineTray.Windows\DiffEngineTray.Windows.csproj" AdditionalProperties="RuntimeIdentifier=win-x64" /> + <_TrayRidPackage Include="..\DiffEngineTray.Windows\DiffEngineTray.Windows.csproj" AdditionalProperties="RuntimeIdentifier=win-arm64" /> + <_TrayRidPackage Include="..\DiffEngineTray.Linux\DiffEngineTray.Linux.csproj" AdditionalProperties="RuntimeIdentifier=linux-x64" /> + <_TrayRidPackage Include="..\DiffEngineTray.Linux\DiffEngineTray.Linux.csproj" AdditionalProperties="RuntimeIdentifier=linux-arm64" /> + <_TrayRidPackage Include="..\DiffEngineTray.Mac\DiffEngineTray.Mac.csproj" AdditionalProperties="RuntimeIdentifier=osx-x64" /> + <_TrayRidPackage Include="..\DiffEngineTray.Mac\DiffEngineTray.Mac.csproj" AdditionalProperties="RuntimeIdentifier=osx-arm64" /> + + + + + diff --git a/src/DiffEngineTray/Program.cs b/src/DiffEngineTray/Program.cs index 92c7068ed..d938a1a19 100644 --- a/src/DiffEngineTray/Program.cs +++ b/src/DiffEngineTray/Program.cs @@ -1,230 +1,3 @@ -static class Program -{ - /// - /// Synchronous, so that the attribute is on the method the runtime starts. For an - /// async Task Main that is a method the compiler writes, which carries none, and the - /// thread every window here lives on came up MTA: Clipboard.SetText throws ThreadStateException - /// there, so "Copy" in the debug view never copied anything. - /// - /// Blocking on is what that compiler written method did as well. Nothing - /// in it awaits until Application.Run() has returned, by which time WinForms has taken - /// its synchronization context back off this thread, so what follows continues on the pool - /// rather than waiting on a loop that is no longer pumping. - /// - /// - [STAThread] - static void Main() - { - IssueLauncher.Declined = IssueQuestion.Declined; - TrayViewerDirectory.Register(); - Logging.Init(); - Application.EnableVisualStyles(); - Application.SetCompatibleTextRenderingDefault(false); - Application.SetHighDpiMode(HighDpiMode.SystemAware); - - try - { - Inner().GetAwaiter().GetResult(); - } - catch (Exception exception) - { - Log.Logger.Fatal(exception, "Failed at startup"); - throw; - } - } - - static async Task Inner() - { - var tokenSource = new CancelSource(); - var cancel = tokenSource.Token; - using var mutex = new Mutex(true, "DiffEngine", out var createdNew); - if (!createdNew) - { - Log.Information("Mutex already exists. Exiting."); - return; - } - - try - { - TrayVersionFile.Write(VersionReader.VersionString); - } - catch (Exception exception) - { - // The marker only gates inline snapshot payloads; the tray must still start - Log.Error(exception, "Failed to write the tray version marker"); - } - - // Before the settings file rather than after it, because nothing between here and the - // first use of a setting needs one, and until this line the user is looking at an empty - // notification area. Still after the mutex, so a second instance exits without ever - // showing an icon. - // The tips are those of the menus last shown, which the turns below hold, and are asked - // for only as a menu is built, by when there are turns to ask - TrayTurns? turns = null; - using var tray = NotifyIconTray.Open(MenuBuilder.Options(_ => turns?.Tooltip(_)), out var trayError) ?? - throw new InvalidOperationException($"Could not make the tray icon. {trayError}"); - // A picture now rather than at the first turn, which waits on everything below - tray.Apply(new(TrayIcon.Default, "DiffEngineTray", [])); - - void Warn(string message) => - tray.Notify(new("DiffEngineTray", message, Kind: TrayNotificationKind.Warning)); - - if (TrayUpdate.Report() is { } report) - { - // A failure as a warning, as every balloon of this tray's is - tray.Notify(report.Kind == TrayNotificationKind.Info ? report : report with {Kind = TrayNotificationKind.Warning}); - } - - var settings = GetSettings(); - if (settings == null) - { - return; - } - - LockedFilesPolicy.AlwaysKill = settings.AlwaysKillLockingProcesses; - - // Ownership of the inline queue is decided here, once, by whether the bind succeeds, and - // never transfers. Usually the tray wins, because it starts at login. A viewer that was - // already running keeps the queue for as long as it lives, and this tray drives it - // remotely for the rest of its own life rather than trying to take over mid flight. - await using var owned = OwnedInlineHost.TryOwn(Warn); - if (owned is null) - { - Log.Information("A viewer owns the inline queue. Driving it remotely."); - } - - var icon = TrayIcon.Default; - await using var tracker = new Tracker( - active: () => icon = TrayIcon.Active, - inactive: () => icon = TrayIcon.Default, - lockedFilesResolver: LockedFilesHandler.Resolve, - acceptFailed: move => Warn( - $"Could not accept '{move.Name}': the file move keeps failing. The move is still pending, so accept can be retried."), - inlineFailed: Warn, - inline: owned, - scanFailing: Warn); - - // Owning the queue means knowing the moment it changes, rather than finding out on the - // next two second scan. Wired before serving starts, so the first patch to arrive counts. - if (owned is not null) - { - owned.Changed = tracker.Refresh; - owned.TrackedFiles = tracker; - owned.Start(); - } - - // A logoff or a shutdown does not come back through Application.Run(), so nothing the - // unwind below performs happens for one - using var sessionEnd = new SessionEndWindow( - TraySessionEnd.For(owned, TrayVersionFile.Delete), - _ => Log.Error(_, "Failed while the session was ending")); - - // Not a using. Anything throwing between here and the await below would dispose a task - // that is still running, and Task.Dispose throws for one that has not completed - which - // would replace whatever actually went wrong with an InvalidOperationException. A task - // needs no disposal anyway; cancelling it is what ends it - var listener = PiperServer.TryBind(out var bindError); - if (listener is null) - { - Log.Error(bindError, "Could not listen on port {Port}", PiperClient.Port); - Warn($"Could not listen on port {PiperClient.Port}, so moves and deletes from test runs will not reach the tray. {bindError!.Message}"); - } - - var task = listener is null ? Task.CompletedTask : PiperServer.Serve(listener, tracker, cancel); - - using var keyRegister = new KeyRegister(tray.Handle); - ReBindKeys(settings, keyRegister, tracker, Warn); - - turns = new( - tray, - tracker, - new( - Exit: Application.Exit, - Options: async () => await OptionsFormLauncher.Launch(keyRegister, tracker), - DebugView: async () => await DebugFormLauncher.Launch(tracker), - Purge: FilePurger.Launch, - OpenDiffTool: DiffToolLauncher.Launch), - () => icon); - - // The tray's events are raised on this thread as Application.Run pumps it, and what they - // chose is done here too, a turn at a time, as the other heads' loops do - using var timer = new System.Windows.Forms.Timer - { - Interval = (int) TrayLoop.Turn.TotalMilliseconds - }; - timer.Tick += (_, _) => turns.Turn(); - timer.Start(); - - try - { - Application.Run(); - } - finally - { - TrayVersionFile.Delete(); - } - - await tokenSource.CancelAsync(); - await task; - } - - internal static void ReBindKeys(Settings settings, KeyRegister keyRegister, Tracker tracker, Action? warn = null) - { - foreach (var binding in BuildKeyBindings(settings, tracker)) - { - var hotKey = binding.HotKey; - if (keyRegister.TryAddBinding( - binding.Id, - hotKey.Shift, - hotKey.Control, - hotKey.Alt, - hotKey.Key, - binding.Action)) - { - continue; - } - - // Said out loud, because the alternative is a hot key that quietly does nothing. Only - // settings.json can produce a key name the Options form cannot, so only a hand edit - // reaches the first half of this - warn?.Invoke( - $"Could not bind the hot key '{hotKey.Key}'. It is either not a key name, or already registered by another application."); - } - } - - // Each configured hot key must map to a distinct KeyBindingIds value. - // Reusing an id causes KeyRegister.TryAddBinding to unregister and overwrite the earlier binding. - internal static IEnumerable BuildKeyBindings(Settings settings, Tracker tracker) - { - if (settings.DiscardAllHotKey is { } discardAll) - { - yield return new(KeyBindingIds.DiscardAll, discardAll, () => tracker.Clear()); - } - - if (settings.AcceptAllHotKey is { } acceptAll) - { - yield return new(KeyBindingIds.AcceptAll, acceptAll, () => tracker.AcceptAll()); - } - - if (settings.AcceptOpenHotKey is { } acceptOpen) - { - yield return new(KeyBindingIds.AcceptOpen, acceptOpen, () => tracker.AcceptOpen()); - } - } - - internal record KeyBinding(int Id, HotKey HotKey, Action Action); - - static Settings? GetSettings() - { - try - { - return SettingsHelper.Read(); - } - catch (Exception exception) - { - Log.Fatal(exception, "Cannot start. Failed to read settings: {FilePath}", SettingsHelper.FilePath); - IssueLauncher.LaunchForException($"Cannot start. Failed to read settings: {SettingsHelper.FilePath}", exception); - return null; - } - } -} \ No newline at end of file +// Never run: DiffEngineTray's package holds only the list of RID packages, and the tray is the head +// one of those installs. See DiffEngineTray.csproj. +return 1;