From f3c052740b7544348996b251280f9d0558963cbc Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Wed, 30 Sep 2026 14:27:00 -0400 Subject: [PATCH 1/4] Support compatibility.isolatedMode When set, a launch neither forwards to a running instance nor becomes the broker, so each process keeps its own environment, parent window handle and settings directory. Persisted layouts are skipped, like Windows Terminal. --- README.md | 7 ++ .../Models/TerminalLayoutStateStore.cs | 7 ++ .../Views/MainWindow.axaml.cs | 3 +- src/Devolutions.Terminal.Cli/Program.cs | 9 ++- src/Devolutions.Terminal.Settings/Models.cs | 1 + .../SettingsLoader.cs | 24 ++++++ .../SettingsService.cs | 16 ++++ src/Devolutions.Terminal/Program.cs | 14 ++-- .../LayoutDescriptorTests.cs | 16 ++++ .../IsolatedModeSettingsTests.cs | 76 +++++++++++++++++++ .../SettingsParityTests.cs | 2 + 11 files changed, 165 insertions(+), 10 deletions(-) create mode 100644 tests/Devolutions.Terminal.Settings.Tests/IsolatedModeSettingsTests.cs diff --git a/README.md b/README.md index af3e20a..8b5b6d5 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,13 @@ 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. +`"compatibility.isolatedMode": true` (from Windows Terminal 1.22 and earlier) +makes each launch 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. + 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..77a3d71 100644 --- a/src/Devolutions.Terminal.App/Models/TerminalLayoutStateStore.cs +++ b/src/Devolutions.Terminal.App/Models/TerminalLayoutStateStore.cs @@ -10,6 +10,13 @@ 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(AppSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + return !settings.IsolatedMode && IsPersistedLayoutPreference(settings.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..0b9480d 100644 --- a/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs +++ b/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs @@ -3647,8 +3647,7 @@ private void PersistWorkspace(TerminalWindowLayoutDescriptor layout) }; private bool UsesPersistedLayout => - TerminalLayoutStateStore.IsPersistedLayoutPreference( - _settings.FirstWindowPreference); + TerminalLayoutStateStore.ShouldUsePersistedLayout(_settings); private void TryPersistCurrentLayout(TerminalWindowLayoutDescriptor layout) { diff --git a/src/Devolutions.Terminal.Cli/Program.cs b/src/Devolutions.Terminal.Cli/Program.cs index 7bbe546..477deef 100644 --- a/src/Devolutions.Terminal.Cli/Program.cs +++ b/src/Devolutions.Terminal.Cli/Program.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using Devolutions.Terminal.Broker; +using Devolutions.Terminal.Settings; namespace Devolutions.Terminal.Cli; @@ -17,9 +18,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 = SettingsService.IsIsolatedModeEnabled() + ? BrokerResponse.Unavailable("Isolated mode does 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.Settings/Models.cs b/src/Devolutions.Terminal.Settings/Models.cs index 28e1b7c..bae7242 100644 --- a/src/Devolutions.Terminal.Settings/Models.cs +++ b/src/Devolutions.Terminal.Settings/Models.cs @@ -131,6 +131,7 @@ public sealed class AppSettings public bool AlwaysShowNotificationIcon { get; set; } public List DisabledProfileSources { get; set; } = []; public bool AllowHeadless { get; set; } + public bool IsolatedMode { get; set; } public bool EnableColorSelection { get; set; } // Per-window settings. diff --git a/src/Devolutions.Terminal.Settings/SettingsLoader.cs b/src/Devolutions.Terminal.Settings/SettingsLoader.cs index 910e1bf..aca1e37 100644 --- a/src/Devolutions.Terminal.Settings/SettingsLoader.cs +++ b/src/Devolutions.Terminal.Settings/SettingsLoader.cs @@ -19,6 +19,7 @@ public static class SettingsLoader { private const string OriginKey = "$terminalOrigin"; private const string SourceKey = "$terminalSource"; + private const string IsolatedModeKey = "compatibility.isolatedMode"; private static readonly JsonDocumentOptions DocumentOptions = new() { @@ -33,6 +34,27 @@ public static string ReadEmbeddedDefaults() public static string ReadEmbeddedUserDefaults() => ReadEmbeddedResource("Devolutions.Terminal.Settings.userDefaults.json"); + /// + /// Reads only compatibility.isolatedMode from a user settings document, so startup can + /// decide whether to use the broker before loading the full settings. + /// + public static bool ReadIsolatedMode(string? userJson) + { + if (string.IsNullOrWhiteSpace(userJson)) + { + return false; + } + + try + { + return Bool(JsonNode.Parse(userJson, documentOptions: DocumentOptions) as JsonObject, IsolatedModeKey); + } + catch (JsonException) + { + return false; + } + } + private static string ReadEmbeddedResource(string name) { using var stream = typeof(SettingsLoader).Assembly.GetManifestResourceStream(name); @@ -320,6 +342,7 @@ private static AppSettings Resolve( AlwaysShowNotificationIcon = Bool(root, "alwaysShowNotificationIcon"), DisabledProfileSources = StringList(root["disabledProfileSources"]), AllowHeadless = Bool(root, "compatibility.allowHeadless"), + IsolatedMode = Bool(root, IsolatedModeKey), EnableColorSelection = Bool(root, "experimental.enableColorSelection"), DefaultProfile = String(root, "defaultProfile"), InitialCols = Int(root, "initialCols", 80, minimum: 1), @@ -1135,6 +1158,7 @@ private static bool GuidsEqual(string? left, string? right) => ["alwaysShowNotificationIcon"] = settings.AlwaysShowNotificationIcon, ["disabledProfileSources"] = StringArray(settings.DisabledProfileSources), ["compatibility.allowHeadless"] = settings.AllowHeadless, + [IsolatedModeKey] = settings.IsolatedMode, ["experimental.enableColorSelection"] = settings.EnableColorSelection, ["defaultProfile"] = settings.DefaultProfile, ["initialCols"] = settings.InitialCols, diff --git a/src/Devolutions.Terminal.Settings/SettingsService.cs b/src/Devolutions.Terminal.Settings/SettingsService.cs index 0953054..98a535a 100644 --- a/src/Devolutions.Terminal.Settings/SettingsService.cs +++ b/src/Devolutions.Terminal.Settings/SettingsService.cs @@ -149,6 +149,22 @@ public static void Save(AppSettings settings) public static AppSettings CreateDefault() => SettingsLoader.Load(SettingsLoader.ReadEmbeddedDefaults()); + public static bool IsIsolatedModeEnabled() => IsIsolatedModeEnabled(SettingsPath); + + public static bool IsIsolatedModeEnabled(string settingsPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(settingsPath); + try + { + return File.Exists(settingsPath) && + SettingsLoader.ReadIsolatedMode(File.ReadAllText(settingsPath, Encoding.UTF8)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } + public static ApplicationStateStore LoadApplicationState() => new(Path.GetDirectoryName(Path.GetFullPath(StatePath))!); diff --git a/src/Devolutions.Terminal/Program.cs b/src/Devolutions.Terminal/Program.cs index fb9b524..70002b1 100644 --- a/src/Devolutions.Terminal/Program.cs +++ b/src/Devolutions.Terminal/Program.cs @@ -67,8 +67,9 @@ public static int Main(string[] args) var invocation = parsed.Invocation!; var deferredHandler = new DeferredBrokerHandler(); - var broker = BrokerHost.TryCreate(deferredHandler); - if (broker is null) + var isolatedMode = Devolutions.Terminal.Settings.SettingsService.IsIsolatedModeEnabled(); + var broker = isolatedMode ? null : BrokerHost.TryCreate(deferredHandler); + if (broker is null && !isolatedMode) { var response = ForwardToPrimaryAsync(invocation).AsTask().GetAwaiter().GetResult(); if (!response.IsSuccess) @@ -83,14 +84,14 @@ public static int Main(string[] args) if (invocation.TargetWindow.Equals("use-existing", StringComparison.OrdinalIgnoreCase) || (int.TryParse(invocation.TargetWindow, out var requestedWindowId) && requestedWindowId > 0)) { - broker.DisposeAsync().AsTask().GetAwaiter().GetResult(); + DisposeBroker(broker); 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(); + DisposeBroker(broker); try { var settings = Devolutions.Terminal.Settings.SettingsService.Load(); @@ -135,10 +136,13 @@ IOException or } finally { - broker.DisposeAsync().AsTask().GetAwaiter().GetResult(); + DisposeBroker(broker); } } + private static void DisposeBroker(BrokerHost? broker) => + broker?.DisposeAsync().AsTask().GetAwaiter().GetResult(); + public static AppBuilder BuildAvaloniaApp() { var builder = AppBuilder.Configure() diff --git a/tests/Devolutions.Terminal.App.Tests/LayoutDescriptorTests.cs b/tests/Devolutions.Terminal.App.Tests/LayoutDescriptorTests.cs index d50785e..4a9f347 100644 --- a/tests/Devolutions.Terminal.App.Tests/LayoutDescriptorTests.cs +++ b/tests/Devolutions.Terminal.App.Tests/LayoutDescriptorTests.cs @@ -28,6 +28,22 @@ 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 isolatedMode, bool expected) + { + var settings = new AppSettings + { + FirstWindowPreference = preference, + IsolatedMode = isolatedMode, + }; + + Assert.Equal(expected, TerminalLayoutStateStore.ShouldUsePersistedLayout(settings)); + } + [Fact] public void WindowTabPaneLayoutRoundTripsThroughApplicationState() { diff --git a/tests/Devolutions.Terminal.Settings.Tests/IsolatedModeSettingsTests.cs b/tests/Devolutions.Terminal.Settings.Tests/IsolatedModeSettingsTests.cs new file mode 100644 index 0000000..218e0f2 --- /dev/null +++ b/tests/Devolutions.Terminal.Settings.Tests/IsolatedModeSettingsTests.cs @@ -0,0 +1,76 @@ +using Devolutions.Terminal.Settings; +using Xunit; + +namespace Devolutions.Terminal.Settings.Tests; + +public sealed class IsolatedModeSettingsTests +{ + [Theory] + [InlineData("""{ "compatibility.isolatedMode": true }""", true)] + [InlineData("""{ "compatibility.isolatedMode": false }""", false)] + [InlineData("""{ "compatibility.isolatedMode": "true" }""", false)] + [InlineData("""{ "compatibility.isolatedMode": null }""", false)] + [InlineData("""{ "compatibility.allowHeadless": true }""", false)] + [InlineData("""{ "profiles": { "defaults": { "compatibility.isolatedMode": true } } }""", false)] + [InlineData("[]", false)] + [InlineData("{ not json", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void ReadsOnlyTheRootBoolean(string? userJson, bool expected) + { + Assert.Equal(expected, SettingsLoader.ReadIsolatedMode(userJson)); + } + + [Fact] + public void ReadsJsoncLikeTheFullLoader() + { + const string user = """ + { + // RDM writes this before every launch. + "compatibility.isolatedMode": true, + } + """; + + Assert.True(SettingsLoader.ReadIsolatedMode(user)); + Assert.True(SettingsLoader.Load(SettingsLoader.ReadEmbeddedDefaults(), user).IsolatedMode); + } + + [Fact] + public void IsOffByDefaultAndSerializedOnlyWhenChanged() + { + var settings = SettingsLoader.Load(SettingsLoader.ReadEmbeddedDefaults()); + Assert.False(settings.IsolatedMode); + Assert.DoesNotContain( + "compatibility.isolatedMode", + SettingsLoader.SerializeUserDocument(settings), + StringComparison.Ordinal); + + settings.IsolatedMode = true; + var output = SettingsLoader.SerializeUserDocument(settings); + + Assert.True(SettingsLoader.ReadIsolatedMode(output)); + Assert.True(SettingsLoader.Load(SettingsLoader.ReadEmbeddedDefaults(), output).IsolatedMode); + } + + [Fact] + public void ReadsTheSettingsFileAtThePath() + { + var root = Path.Combine(Path.GetTempPath(), "dterm-isolated-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var path = Path.Combine(root, "settings.json"); + try + { + Assert.False(SettingsService.IsIsolatedModeEnabled(path)); + + File.WriteAllText(path, """{ "compatibility.isolatedMode": true }"""); + Assert.True(SettingsService.IsIsolatedModeEnabled(path)); + + File.WriteAllText(path, """{ "compatibility.isolatedMode": false }"""); + Assert.False(SettingsService.IsIsolatedModeEnabled(path)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } +} diff --git a/tests/Devolutions.Terminal.Settings.Tests/SettingsParityTests.cs b/tests/Devolutions.Terminal.Settings.Tests/SettingsParityTests.cs index a25b45c..393535c 100644 --- a/tests/Devolutions.Terminal.Settings.Tests/SettingsParityTests.cs +++ b/tests/Devolutions.Terminal.Settings.Tests/SettingsParityTests.cs @@ -68,6 +68,7 @@ public void ProjectsEveryGlobalAndWindowSettingsGroup() "windowingBehavior": "useAnyExisting", "disabledProfileSources": ["A", "B"], "compatibility.allowHeadless": true, + "compatibility.isolatedMode": true, "experimental.enableColorSelection": true, "initialRows": 42, "initialCols": 132, @@ -105,6 +106,7 @@ public void ProjectsEveryGlobalAndWindowSettingsGroup() Assert.Equal("useAnyExisting", settings.WindowingBehavior); Assert.Equal(["A", "B"], settings.DisabledProfileSources); Assert.True(settings.AllowHeadless); + Assert.True(settings.IsolatedMode); Assert.True(settings.EnableColorSelection); Assert.Equal(42, settings.InitialRows); Assert.Equal(132, settings.InitialCols); From 76089e0109124e52bc602ced851901a0b582527d Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Wed, 30 Sep 2026 15:11:32 -0400 Subject: [PATCH 2/4] Use a --isolated flag instead of the setting The setting is silently ignored by a dt that does not know it, which would leak one launch's environment into another. An unknown flag fails the launch instead, the caller decides per launch, and nothing has to be read from settings.json before the broker election. --- README.md | 13 ++-- .../Models/TerminalLayoutStateStore.cs | 7 +- .../Views/MainWindow.axaml.cs | 7 +- src/Devolutions.Terminal.Cli/CliContracts.cs | 3 +- src/Devolutions.Terminal.Cli/CliParser.cs | 16 +++- src/Devolutions.Terminal.Cli/Program.cs | 5 +- src/Devolutions.Terminal.Settings/Models.cs | 1 - .../SettingsLoader.cs | 24 ------ .../SettingsService.cs | 16 ---- src/Devolutions.Terminal/App.axaml.cs | 5 +- src/Devolutions.Terminal/Program.cs | 5 +- .../TerminalWindowRouter.cs | 8 +- .../LayoutDescriptorTests.cs | 10 +-- .../CliParserTests.cs | 23 ++++++ .../IsolatedModeSettingsTests.cs | 76 ------------------- .../SettingsParityTests.cs | 2 - 16 files changed, 68 insertions(+), 153 deletions(-) delete mode 100644 tests/Devolutions.Terminal.Settings.Tests/IsolatedModeSettingsTests.cs diff --git a/README.md b/README.md index 8b5b6d5..442f5f7 100644 --- a/README.md +++ b/README.md @@ -88,12 +88,13 @@ 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. -`"compatibility.isolatedMode": true` (from Windows Terminal 1.22 and earlier) -makes each launch 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. +`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 diff --git a/src/Devolutions.Terminal.App/Models/TerminalLayoutStateStore.cs b/src/Devolutions.Terminal.App/Models/TerminalLayoutStateStore.cs index 77a3d71..59a9d69 100644 --- a/src/Devolutions.Terminal.App/Models/TerminalLayoutStateStore.cs +++ b/src/Devolutions.Terminal.App/Models/TerminalLayoutStateStore.cs @@ -11,11 +11,8 @@ preference is not null && preference.Equals("persistedLayoutAndContent", StringComparison.OrdinalIgnoreCase)); // Matches Windows Terminal: an isolated process never restores or saves the shared window layouts. - public static bool ShouldUsePersistedLayout(AppSettings settings) - { - ArgumentNullException.ThrowIfNull(settings); - return !settings.IsolatedMode && IsPersistedLayoutPreference(settings.FirstWindowPreference); - } + public static bool ShouldUsePersistedLayout(string? firstWindowPreference, bool isolated) => + !isolated && IsPersistedLayoutPreference(firstWindowPreference); public static WindowLayoutState? ReadWindowState(ApplicationStateStore store, int windowId) { diff --git a/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs b/src/Devolutions.Terminal.App/Views/MainWindow.axaml.cs index 0b9480d..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,7 +3650,7 @@ private void PersistWorkspace(TerminalWindowLayoutDescriptor layout) }; private bool UsesPersistedLayout => - TerminalLayoutStateStore.ShouldUsePersistedLayout(_settings); + 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 477deef..252a65c 100644 --- a/src/Devolutions.Terminal.Cli/Program.cs +++ b/src/Devolutions.Terminal.Cli/Program.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using Devolutions.Terminal.Broker; -using Devolutions.Terminal.Settings; namespace Devolutions.Terminal.Cli; @@ -18,8 +17,8 @@ public static async Task Main(string[] args) } var invocation = parsed.Invocation!; - var response = SettingsService.IsIsolatedModeEnabled() - ? BrokerResponse.Unavailable("Isolated mode does not use the broker.") + var response = invocation.Isolated + ? BrokerResponse.Unavailable("Isolated launches do not use the broker.") : await new BrokerClient().SendAsync( invocation.TargetWindow, CliInvocationSerializer.Serialize(invocation)).ConfigureAwait(false); diff --git a/src/Devolutions.Terminal.Settings/Models.cs b/src/Devolutions.Terminal.Settings/Models.cs index bae7242..28e1b7c 100644 --- a/src/Devolutions.Terminal.Settings/Models.cs +++ b/src/Devolutions.Terminal.Settings/Models.cs @@ -131,7 +131,6 @@ public sealed class AppSettings public bool AlwaysShowNotificationIcon { get; set; } public List DisabledProfileSources { get; set; } = []; public bool AllowHeadless { get; set; } - public bool IsolatedMode { get; set; } public bool EnableColorSelection { get; set; } // Per-window settings. diff --git a/src/Devolutions.Terminal.Settings/SettingsLoader.cs b/src/Devolutions.Terminal.Settings/SettingsLoader.cs index aca1e37..910e1bf 100644 --- a/src/Devolutions.Terminal.Settings/SettingsLoader.cs +++ b/src/Devolutions.Terminal.Settings/SettingsLoader.cs @@ -19,7 +19,6 @@ public static class SettingsLoader { private const string OriginKey = "$terminalOrigin"; private const string SourceKey = "$terminalSource"; - private const string IsolatedModeKey = "compatibility.isolatedMode"; private static readonly JsonDocumentOptions DocumentOptions = new() { @@ -34,27 +33,6 @@ public static string ReadEmbeddedDefaults() public static string ReadEmbeddedUserDefaults() => ReadEmbeddedResource("Devolutions.Terminal.Settings.userDefaults.json"); - /// - /// Reads only compatibility.isolatedMode from a user settings document, so startup can - /// decide whether to use the broker before loading the full settings. - /// - public static bool ReadIsolatedMode(string? userJson) - { - if (string.IsNullOrWhiteSpace(userJson)) - { - return false; - } - - try - { - return Bool(JsonNode.Parse(userJson, documentOptions: DocumentOptions) as JsonObject, IsolatedModeKey); - } - catch (JsonException) - { - return false; - } - } - private static string ReadEmbeddedResource(string name) { using var stream = typeof(SettingsLoader).Assembly.GetManifestResourceStream(name); @@ -342,7 +320,6 @@ private static AppSettings Resolve( AlwaysShowNotificationIcon = Bool(root, "alwaysShowNotificationIcon"), DisabledProfileSources = StringList(root["disabledProfileSources"]), AllowHeadless = Bool(root, "compatibility.allowHeadless"), - IsolatedMode = Bool(root, IsolatedModeKey), EnableColorSelection = Bool(root, "experimental.enableColorSelection"), DefaultProfile = String(root, "defaultProfile"), InitialCols = Int(root, "initialCols", 80, minimum: 1), @@ -1158,7 +1135,6 @@ private static bool GuidsEqual(string? left, string? right) => ["alwaysShowNotificationIcon"] = settings.AlwaysShowNotificationIcon, ["disabledProfileSources"] = StringArray(settings.DisabledProfileSources), ["compatibility.allowHeadless"] = settings.AllowHeadless, - [IsolatedModeKey] = settings.IsolatedMode, ["experimental.enableColorSelection"] = settings.EnableColorSelection, ["defaultProfile"] = settings.DefaultProfile, ["initialCols"] = settings.InitialCols, diff --git a/src/Devolutions.Terminal.Settings/SettingsService.cs b/src/Devolutions.Terminal.Settings/SettingsService.cs index 98a535a..0953054 100644 --- a/src/Devolutions.Terminal.Settings/SettingsService.cs +++ b/src/Devolutions.Terminal.Settings/SettingsService.cs @@ -149,22 +149,6 @@ public static void Save(AppSettings settings) public static AppSettings CreateDefault() => SettingsLoader.Load(SettingsLoader.ReadEmbeddedDefaults()); - public static bool IsIsolatedModeEnabled() => IsIsolatedModeEnabled(SettingsPath); - - public static bool IsIsolatedModeEnabled(string settingsPath) - { - ArgumentException.ThrowIfNullOrWhiteSpace(settingsPath); - try - { - return File.Exists(settingsPath) && - SettingsLoader.ReadIsolatedMode(File.ReadAllText(settingsPath, Encoding.UTF8)); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - return false; - } - } - public static ApplicationStateStore LoadApplicationState() => new(Path.GetDirectoryName(Path.GetFullPath(StatePath))!); 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 70002b1..1a9959e 100644 --- a/src/Devolutions.Terminal/Program.cs +++ b/src/Devolutions.Terminal/Program.cs @@ -67,9 +67,8 @@ public static int Main(string[] args) var invocation = parsed.Invocation!; var deferredHandler = new DeferredBrokerHandler(); - var isolatedMode = Devolutions.Terminal.Settings.SettingsService.IsIsolatedModeEnabled(); - var broker = isolatedMode ? null : BrokerHost.TryCreate(deferredHandler); - if (broker is null && !isolatedMode) + var broker = invocation.Isolated ? null : BrokerHost.TryCreate(deferredHandler); + if (broker is null && !invocation.Isolated) { var response = ForwardToPrimaryAsync(invocation).AsTask().GetAwaiter().GetResult(); if (!response.IsSuccess) diff --git a/src/Devolutions.Terminal/TerminalWindowRouter.cs b/src/Devolutions.Terminal/TerminalWindowRouter.cs index d4f60f6..b481a21 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); diff --git a/tests/Devolutions.Terminal.App.Tests/LayoutDescriptorTests.cs b/tests/Devolutions.Terminal.App.Tests/LayoutDescriptorTests.cs index 4a9f347..945abca 100644 --- a/tests/Devolutions.Terminal.App.Tests/LayoutDescriptorTests.cs +++ b/tests/Devolutions.Terminal.App.Tests/LayoutDescriptorTests.cs @@ -33,15 +33,9 @@ public void NonPersistedFirstWindowPreferencesDoNotRestoreLayouts(string? prefer [InlineData("persistedWindowLayout", true, false)] [InlineData("defaultProfile", false, false)] [InlineData("defaultProfile", true, false)] - public void IsolatedModeNeverUsesPersistedLayouts(string preference, bool isolatedMode, bool expected) + public void IsolatedModeNeverUsesPersistedLayouts(string preference, bool isolated, bool expected) { - var settings = new AppSettings - { - FirstWindowPreference = preference, - IsolatedMode = isolatedMode, - }; - - Assert.Equal(expected, TerminalLayoutStateStore.ShouldUsePersistedLayout(settings)); + Assert.Equal(expected, TerminalLayoutStateStore.ShouldUsePersistedLayout(preference, isolated)); } [Fact] 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] diff --git a/tests/Devolutions.Terminal.Settings.Tests/IsolatedModeSettingsTests.cs b/tests/Devolutions.Terminal.Settings.Tests/IsolatedModeSettingsTests.cs deleted file mode 100644 index 218e0f2..0000000 --- a/tests/Devolutions.Terminal.Settings.Tests/IsolatedModeSettingsTests.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Devolutions.Terminal.Settings; -using Xunit; - -namespace Devolutions.Terminal.Settings.Tests; - -public sealed class IsolatedModeSettingsTests -{ - [Theory] - [InlineData("""{ "compatibility.isolatedMode": true }""", true)] - [InlineData("""{ "compatibility.isolatedMode": false }""", false)] - [InlineData("""{ "compatibility.isolatedMode": "true" }""", false)] - [InlineData("""{ "compatibility.isolatedMode": null }""", false)] - [InlineData("""{ "compatibility.allowHeadless": true }""", false)] - [InlineData("""{ "profiles": { "defaults": { "compatibility.isolatedMode": true } } }""", false)] - [InlineData("[]", false)] - [InlineData("{ not json", false)] - [InlineData("", false)] - [InlineData(null, false)] - public void ReadsOnlyTheRootBoolean(string? userJson, bool expected) - { - Assert.Equal(expected, SettingsLoader.ReadIsolatedMode(userJson)); - } - - [Fact] - public void ReadsJsoncLikeTheFullLoader() - { - const string user = """ - { - // RDM writes this before every launch. - "compatibility.isolatedMode": true, - } - """; - - Assert.True(SettingsLoader.ReadIsolatedMode(user)); - Assert.True(SettingsLoader.Load(SettingsLoader.ReadEmbeddedDefaults(), user).IsolatedMode); - } - - [Fact] - public void IsOffByDefaultAndSerializedOnlyWhenChanged() - { - var settings = SettingsLoader.Load(SettingsLoader.ReadEmbeddedDefaults()); - Assert.False(settings.IsolatedMode); - Assert.DoesNotContain( - "compatibility.isolatedMode", - SettingsLoader.SerializeUserDocument(settings), - StringComparison.Ordinal); - - settings.IsolatedMode = true; - var output = SettingsLoader.SerializeUserDocument(settings); - - Assert.True(SettingsLoader.ReadIsolatedMode(output)); - Assert.True(SettingsLoader.Load(SettingsLoader.ReadEmbeddedDefaults(), output).IsolatedMode); - } - - [Fact] - public void ReadsTheSettingsFileAtThePath() - { - var root = Path.Combine(Path.GetTempPath(), "dterm-isolated-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - var path = Path.Combine(root, "settings.json"); - try - { - Assert.False(SettingsService.IsIsolatedModeEnabled(path)); - - File.WriteAllText(path, """{ "compatibility.isolatedMode": true }"""); - Assert.True(SettingsService.IsIsolatedModeEnabled(path)); - - File.WriteAllText(path, """{ "compatibility.isolatedMode": false }"""); - Assert.False(SettingsService.IsIsolatedModeEnabled(path)); - } - finally - { - Directory.Delete(root, recursive: true); - } - } -} diff --git a/tests/Devolutions.Terminal.Settings.Tests/SettingsParityTests.cs b/tests/Devolutions.Terminal.Settings.Tests/SettingsParityTests.cs index 393535c..a25b45c 100644 --- a/tests/Devolutions.Terminal.Settings.Tests/SettingsParityTests.cs +++ b/tests/Devolutions.Terminal.Settings.Tests/SettingsParityTests.cs @@ -68,7 +68,6 @@ public void ProjectsEveryGlobalAndWindowSettingsGroup() "windowingBehavior": "useAnyExisting", "disabledProfileSources": ["A", "B"], "compatibility.allowHeadless": true, - "compatibility.isolatedMode": true, "experimental.enableColorSelection": true, "initialRows": 42, "initialCols": 132, @@ -106,7 +105,6 @@ public void ProjectsEveryGlobalAndWindowSettingsGroup() Assert.Equal("useAnyExisting", settings.WindowingBehavior); Assert.Equal(["A", "B"], settings.DisabledProfileSources); Assert.True(settings.AllowHeadless); - Assert.True(settings.IsolatedMode); Assert.True(settings.EnableColorSelection); Assert.Equal(42, settings.InitialRows); Assert.Equal(132, settings.InitialCols); From e8ec6e7d7c61eb927e67d61d537956428bf00d42 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Wed, 30 Sep 2026 17:17:05 -0400 Subject: [PATCH 3/4] Simplify isolated broker skip in the host --- src/Devolutions.Terminal/Program.cs | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/Devolutions.Terminal/Program.cs b/src/Devolutions.Terminal/Program.cs index 1a9959e..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 = invocation.Isolated ? null : BrokerHost.TryCreate(deferredHandler); - if (broker is null && !invocation.Isolated) + 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)) { - DisposeBroker(broker); + 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) { - DisposeBroker(broker); + broker?.DisposeAsync().AsTask().GetAwaiter().GetResult(); try { var settings = Devolutions.Terminal.Settings.SettingsService.Load(); @@ -135,13 +139,10 @@ IOException or } finally { - DisposeBroker(broker); + broker?.DisposeAsync().AsTask().GetAwaiter().GetResult(); } } - private static void DisposeBroker(BrokerHost? broker) => - broker?.DisposeAsync().AsTask().GetAwaiter().GetResult(); - public static AppBuilder BuildAvaloniaApp() { var builder = AppBuilder.Configure() From 566f70fa645e4bc8f19a76f470ee84d032d5b9b4 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Wed, 30 Sep 2026 18:11:00 -0400 Subject: [PATCH 4/4] Reject --isolated in the command palette --- src/Devolutions.Terminal/TerminalWindowRouter.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Devolutions.Terminal/TerminalWindowRouter.cs b/src/Devolutions.Terminal/TerminalWindowRouter.cs index b481a21..1acedd3 100644 --- a/src/Devolutions.Terminal/TerminalWindowRouter.cs +++ b/src/Devolutions.Terminal/TerminalWindowRouter.cs @@ -229,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.", []); }