diff --git a/README.md b/README.md index af3e20a..442f5f7 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,14 @@ settings file. On Windows, `WT_PARENT_WINDOW_HANDLE` embeds the window as a child of that HWND. `alwaysShowTabs: false` hides the tab row when only one tab is open. +`dt --isolated` (or `Devolutions.Terminal.exe --isolated`) runs the launch in +its own process: it neither forwards to a running instance nor becomes the +broker for later launches, so every window keeps its own environment, +`WT_PARENT_WINDOW_HANDLE`, and settings directory. `-w` targeting of other +windows is unavailable, and persisted layouts are neither restored nor saved. +It is the per-launch equivalent of Windows Terminal's +`compatibility.isolatedMode` setting, which Windows Terminal 1.23 removed. + Set `"experimental.terminalEngine": "ghostty"` to use the pinned `libghostty-vt` engine globally. A profile can override it with `"builtin"` or `"ghostty"`. ConPTY remains the Windows process transport for both engines. diff --git a/src/Devolutions.Terminal.App/Models/TerminalLayoutStateStore.cs b/src/Devolutions.Terminal.App/Models/TerminalLayoutStateStore.cs index b1af153..59a9d69 100644 --- a/src/Devolutions.Terminal.App/Models/TerminalLayoutStateStore.cs +++ b/src/Devolutions.Terminal.App/Models/TerminalLayoutStateStore.cs @@ -10,6 +10,10 @@ preference is not null && preference.Equals("persistedWindowLayout", StringComparison.OrdinalIgnoreCase) || preference.Equals("persistedLayoutAndContent", StringComparison.OrdinalIgnoreCase)); + // Matches Windows Terminal: an isolated process never restores or saves the shared window layouts. + public static bool ShouldUsePersistedLayout(string? firstWindowPreference, bool isolated) => + !isolated && IsPersistedLayoutPreference(firstWindowPreference); + public static WindowLayoutState? ReadWindowState(ApplicationStateStore store, int windowId) { ArgumentNullException.ThrowIfNull(store); diff --git a/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs b/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs index f82f59f..2cad94d 100644 --- a/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs +++ b/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs @@ -77,6 +77,7 @@ public partial class MainWindow : private readonly Func>? _summonRequested; private readonly Action? _settingsChanged; private readonly ISystemMenuService _systemMenuService; + private readonly bool _isolated; private readonly TaskCompletionSource _initialActivationCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly DispatcherTimer _notificationTimer; @@ -102,10 +103,12 @@ public MainWindow( Action? workspaceRequested = null, Func>? summonRequested = null, Action? settingsChanged = null, - ISystemMenuService? systemMenuService = null) + ISystemMenuService? systemMenuService = null, + bool isolated = false) { WindowId = windowId; WindowName = windowName; + _isolated = isolated; _initialActivation = initialActivation; _newWindowRequested = newWindowRequested; _tabTearOffRequested = tabTearOffRequested; @@ -3647,8 +3650,7 @@ private void PersistWorkspace(TerminalWindowLayoutDescriptor layout) }; private bool UsesPersistedLayout => - TerminalLayoutStateStore.IsPersistedLayoutPreference( - _settings.FirstWindowPreference); + TerminalLayoutStateStore.ShouldUsePersistedLayout(_settings.FirstWindowPreference, _isolated); private void TryPersistCurrentLayout(TerminalWindowLayoutDescriptor layout) { diff --git a/src/Devolutions.Terminal.Cli/CliContracts.cs b/src/Devolutions.Terminal.Cli/CliContracts.cs index fe84632..db729c7 100644 --- a/src/Devolutions.Terminal.Cli/CliContracts.cs +++ b/src/Devolutions.Terminal.Cli/CliContracts.cs @@ -24,7 +24,8 @@ public sealed record CliInvocation( CliLaunchMode LaunchMode, int? SavedLayout, IReadOnlyList Actions, - CliSaveRequest? SaveRequest = null); + CliSaveRequest? SaveRequest = null, + bool Isolated = false); public sealed record CliParseResult( int ExitCode, diff --git a/src/Devolutions.Terminal.Cli/CliParser.cs b/src/Devolutions.Terminal.Cli/CliParser.cs index 8a7bbd7..8c9f5c2 100644 --- a/src/Devolutions.Terminal.Cli/CliParser.cs +++ b/src/Devolutions.Terminal.Cli/CliParser.cs @@ -118,6 +118,7 @@ private static CliParseResult ParseCore( int? rows = null; int? savedLayout = null; var launchMode = CliLaunchMode.Default; + var isolated = false; CliSaveRequest? save = null; for (var segmentIndex = 0; segmentIndex < segments.Count; segmentIndex++) @@ -135,7 +136,8 @@ private static CliParseResult ParseCore( ref columns, ref rows, ref savedLayout, - ref launchMode); + ref launchMode, + ref isolated); } var command = index < segment.Count && Commands.Contains(segment[index]) @@ -215,7 +217,8 @@ save is null && launchMode, savedLayout, actions, - save); + save, + isolated); return new(0, string.Empty, false, invocation); } @@ -228,7 +231,8 @@ private static void ParseRootOptions( ref int? columns, ref int? rows, ref int? savedLayout, - ref CliLaunchMode launchMode) + ref CliLaunchMode launchMode, + ref bool isolated) { while (index < args.Count) { @@ -269,6 +273,10 @@ private static void ParseRootOptions( launchMode |= CliLaunchMode.Focus; index++; break; + case "--isolated": + isolated = true; + index++; + break; default: if (option.StartsWith('-') && option.Length > 2 && @@ -696,6 +704,7 @@ internal static class CommandLineSchema -M, --maximized Launch maximized -F, --fullscreen Launch fullscreen -f, --focus Hide the title bar + --isolated Own process: never join or serve other launches Commands: new-tab, nt split-pane, sp focus-tab, ft move-focus, mf @@ -708,6 +717,7 @@ public static RootCommand Create() root.Options.Add(new Option("--window") { Description = "Target window." }); root.Options.Add(new Option("--pos") { Description = "Window position." }); root.Options.Add(new Option("--size") { Description = "Terminal size." }); + root.Options.Add(new Option("--isolated") { Description = "Run in its own process." }); foreach (var (name, alias) in new[] { ("new-tab", "nt"), diff --git a/src/Devolutions.Terminal.Cli/Program.cs b/src/Devolutions.Terminal.Cli/Program.cs index 7bbe546..252a65c 100644 --- a/src/Devolutions.Terminal.Cli/Program.cs +++ b/src/Devolutions.Terminal.Cli/Program.cs @@ -17,9 +17,11 @@ public static async Task Main(string[] args) } var invocation = parsed.Invocation!; - var response = await new BrokerClient().SendAsync( - invocation.TargetWindow, - CliInvocationSerializer.Serialize(invocation)).ConfigureAwait(false); + var response = invocation.Isolated + ? BrokerResponse.Unavailable("Isolated launches do not use the broker.") + : await new BrokerClient().SendAsync( + invocation.TargetWindow, + CliInvocationSerializer.Serialize(invocation)).ConfigureAwait(false); if (response.Status == BrokerStatus.Unavailable) { if (RequiresExistingWindow(invocation.TargetWindow)) diff --git a/src/Devolutions.Terminal/App.axaml.cs b/src/Devolutions.Terminal/App.axaml.cs index 0a0dfd3..4959c9f 100644 --- a/src/Devolutions.Terminal/App.axaml.cs +++ b/src/Devolutions.Terminal/App.axaml.cs @@ -30,7 +30,10 @@ public override void OnFrameworkInitializationCompleted() if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { _desktop = desktop; - _router = new TerminalWindowRouter(desktop, ConfigureWindow); + _router = new TerminalWindowRouter( + desktop, + ConfigureWindow, + isolated: InitialInvocation?.Isolated ?? false); BrokerHandler?.SetHandler(_router); if (OperatingSystem.IsMacOS()) { diff --git a/src/Devolutions.Terminal/Program.cs b/src/Devolutions.Terminal/Program.cs index fb9b524..d32f460 100644 --- a/src/Devolutions.Terminal/Program.cs +++ b/src/Devolutions.Terminal/Program.cs @@ -67,30 +67,34 @@ public static int Main(string[] args) var invocation = parsed.Invocation!; var deferredHandler = new DeferredBrokerHandler(); - var broker = BrokerHost.TryCreate(deferredHandler); - if (broker is null) + BrokerHost? broker = null; + if (!invocation.Isolated) { - var response = ForwardToPrimaryAsync(invocation).AsTask().GetAwaiter().GetResult(); - if (!response.IsSuccess) + broker = BrokerHost.TryCreate(deferredHandler); + if (broker is null) { - Console.Error.WriteLine($"dt: {response.Message}"); - return response.Status == BrokerStatus.WindowNotFound ? 3 : 1; - } + var response = ForwardToPrimaryAsync(invocation).AsTask().GetAwaiter().GetResult(); + if (!response.IsSuccess) + { + Console.Error.WriteLine($"dt: {response.Message}"); + return response.Status == BrokerStatus.WindowNotFound ? 3 : 1; + } - return 0; + return 0; + } } if (invocation.TargetWindow.Equals("use-existing", StringComparison.OrdinalIgnoreCase) || (int.TryParse(invocation.TargetWindow, out var requestedWindowId) && requestedWindowId > 0)) { - broker.DisposeAsync().AsTask().GetAwaiter().GetResult(); + broker?.DisposeAsync().AsTask().GetAwaiter().GetResult(); Console.Error.WriteLine($"dt: terminal window '{invocation.TargetWindow}' was not found."); return 3; } if (invocation.SaveRequest is { Commandline.Length: > 0 } saveRequest) { - broker.DisposeAsync().AsTask().GetAwaiter().GetResult(); + broker?.DisposeAsync().AsTask().GetAwaiter().GetResult(); try { var settings = Devolutions.Terminal.Settings.SettingsService.Load(); @@ -135,7 +139,7 @@ IOException or } finally { - broker.DisposeAsync().AsTask().GetAwaiter().GetResult(); + broker?.DisposeAsync().AsTask().GetAwaiter().GetResult(); } } diff --git a/src/Devolutions.Terminal/TerminalWindowRouter.cs b/src/Devolutions.Terminal/TerminalWindowRouter.cs index d4f60f6..1acedd3 100644 --- a/src/Devolutions.Terminal/TerminalWindowRouter.cs +++ b/src/Devolutions.Terminal/TerminalWindowRouter.cs @@ -17,16 +17,19 @@ internal sealed class TerminalWindowRouter : IBrokerRequestHandler, IDisposable private readonly ApplicationStateStore _stateStore; private readonly GlobalWindowActionRouter _windowActions; private readonly GlobalHotkeyManager _globalHotkeys; + private readonly bool _isolated; private int _nextWindowId = 1; public TerminalWindowRouter( IClassicDesktopStyleApplicationLifetime desktop, Action? windowCreated = null, ApplicationStateStore? stateStore = null, - IGlobalHotkeyBackend? globalHotkeyBackend = null) + IGlobalHotkeyBackend? globalHotkeyBackend = null, + bool isolated = false) { _desktop = desktop; _windowCreated = windowCreated; + _isolated = isolated; _stateStore = stateStore ?? SettingsService.LoadApplicationState(); _windowActions = new GlobalWindowActionRouter(CreateSummonWindow); _globalHotkeys = new GlobalHotkeyManager( @@ -146,7 +149,8 @@ private MainWindow CreateWindow(TerminalWindowActivation activation, string name : $"{window.WindowName} ({window.WindowId})") .ToArray(), summonRequested: args => _windowActions.SummonAsync(window, args), - settingsChanged: settings => TraceHotkeyResults(_globalHotkeys.Apply(settings.ActionMap))); + settingsChanged: settings => TraceHotkeyResults(_globalHotkeys.Apply(settings.ActionMap)), + isolated: _isolated); _windows.Add(window); _windowActions.Add(window); _windowCreated?.Invoke(window); @@ -225,11 +229,12 @@ parsed.Invocation.PositionX is not null || parsed.Invocation.PositionY is not null || parsed.Invocation.Columns is not null || parsed.Invocation.Rows is not null || - parsed.Invocation.LaunchMode != CliLaunchMode.Default) + parsed.Invocation.LaunchMode != CliLaunchMode.Default || + parsed.Invocation.Isolated) { return new( false, - "Window routing, position, size, and launch-mode options are not valid inside the current window's command palette.", + "Window routing, position, size, launch-mode, and isolation options are not valid inside the current window's command palette.", []); } diff --git a/tests/Devolutions.Terminal.App.Tests/LayoutDescriptorTests.cs b/tests/Devolutions.Terminal.App.Tests/LayoutDescriptorTests.cs index d50785e..945abca 100644 --- a/tests/Devolutions.Terminal.App.Tests/LayoutDescriptorTests.cs +++ b/tests/Devolutions.Terminal.App.Tests/LayoutDescriptorTests.cs @@ -28,6 +28,16 @@ public void NonPersistedFirstWindowPreferencesDoNotRestoreLayouts(string? prefer Assert.False(TerminalLayoutStateStore.IsPersistedLayoutPreference(preference)); } + [Theory] + [InlineData("persistedWindowLayout", false, true)] + [InlineData("persistedWindowLayout", true, false)] + [InlineData("defaultProfile", false, false)] + [InlineData("defaultProfile", true, false)] + public void IsolatedModeNeverUsesPersistedLayouts(string preference, bool isolated, bool expected) + { + Assert.Equal(expected, TerminalLayoutStateStore.ShouldUsePersistedLayout(preference, isolated)); + } + [Fact] public void WindowTabPaneLayoutRoundTripsThroughApplicationState() { diff --git a/tests/Devolutions.Terminal.Cli.Tests/CliParserTests.cs b/tests/Devolutions.Terminal.Cli.Tests/CliParserTests.cs index 6d71ef7..9554a07 100644 --- a/tests/Devolutions.Terminal.Cli.Tests/CliParserTests.cs +++ b/tests/Devolutions.Terminal.Cli.Tests/CliParserTests.cs @@ -122,6 +122,29 @@ public void RootLaunchOptionsArePreserved() Assert.Equal(40, result.Rows); Assert.True(result.LaunchMode.HasFlag(CliLaunchMode.Maximized)); Assert.True(result.LaunchMode.HasFlag(CliLaunchMode.Focus)); + Assert.False(result.Isolated); + } + + [Fact] + public void IsolatedIsARootOptionThatKeepsTheCommandLine() + { + var result = Parse("--isolated", "-w", "new", "new-tab", "-p", "cmd", "cmd.exe", "/k", "--isolated"); + + Assert.True(result.Isolated); + Assert.Equal("new", result.TargetWindow); + var terminal = Assert.IsType( + Assert.IsType(Assert.Single(result.Actions).Args).ContentArgs); + Assert.Equal("cmd", terminal.Profile); + Assert.Equal("cmd.exe /k --isolated", terminal.Commandline); + } + + [Fact] + public void IsolatedAfterACommandIsRejected() + { + var result = new CliParser().Parse(["new-tab", "--isolated"]); + + Assert.Equal(2, result.ExitCode); + Assert.Equal("dt: Unknown command '--isolated'.", result.Message); } [Fact]