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;