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;