From 53fb14696691f57c924e47ed8be9ef4d5d80717b Mon Sep 17 00:00:00 2001
From: Sergey Fedorov <barracuda@macos-powerpc.org>
Date: Mon, 20 Jul 2026 05:30:48 +0000
Subject: [PATCH 59/71] feat(macos): persistent settings via NSUserDefaults
 (font/size/theme/renderer/shell/ssh)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Read defaults from NSUserDefaults (org.contourterminal.Contour) so settings
persist across launches and are editable now with the `defaults` CLI, e.g.
  defaults write org.contourterminal.Contour fontFamily Monaco
  defaults write org.contourterminal.Contour fontSize -float 13
  defaults write org.contourterminal.Contour theme light
  defaults write org.contourterminal.Contour renderer gl
  defaults write org.contourterminal.Contour shell /bin/zsh
Resolution order per setting: environment variable (CONTOUR_RENDER/THEME/SSH,
kept for quick testing) > NSUserDefaults > built-in default. This is the
persistence layer a future preferences UI (#9) and config file (#24) build on
— same keys, no parser needed.

font family/size were previously hardcoded (Menlo/14) — now read from defaults.
Shell was always the account login shell (pw_shell; the engine's loginShell()
does not consult $SHELL) — now overridable: threaded a shellOverride through
bridge_create -> TerminalSession -> makeLocalPty, used when non-empty.

Explicit ternaries, not the `?:` GNU shorthand (not standard C++, and the .mm
is built at gnu++98 by gcc-4.2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeG2jwYMX5gdvvtUnx3PJa
---
 src/contour_macos/SessionBridge.cpp   |  8 +++-
 src/contour_macos/SessionBridge.h     |  3 +-
 src/contour_macos/TerminalSession.cpp | 17 +++++---
 src/contour_macos/TerminalSession.h   |  3 +-
 src/contour_macos/TerminalView.h      |  3 +-
 src/contour_macos/TerminalView.mm     |  4 +-
 src/contour_macos/main.mm             | 58 +++++++++++++++++++--------
 7 files changed, 68 insertions(+), 28 deletions(-)

diff --git a/src/contour_macos/SessionBridge.cpp b/src/contour_macos/SessionBridge.cpp
index 1b4e23c5..bbbfc577 100644
--- a/src/contour_macos/SessionBridge.cpp
+++ b/src/contour_macos/SessionBridge.cpp
@@ -73,7 +73,8 @@ TerminalSession* bridge_create(int widthPx,
                                BridgeCallbacks const& cb,
                                int renderMode,
                                char const* themeName,
-                               BridgeSshConfig const& ssh)
+                               BridgeSshConfig const& ssh,
+                               char const* shellOverride)
 {
     auto fonts = vtrasterizer::FontDescriptions {};
     fonts.dpi = text::DPI { font.dpiX, font.dpiY };
@@ -144,6 +145,8 @@ TerminalSession* bridge_create(int widthPx,
         sshOptions.knownHostsFile = ssh.knownHostsFile ? ssh.knownHostsFile : "";
     }
 
+    std::string const shell = shellOverride ? shellOverride : "";
+
     auto const pageSize = vtbackend::PageSize { vtbackend::LineCount(24), vtbackend::ColumnCount(80) };
     return new TerminalSession(pageSize,
                                std::move(fonts),
@@ -151,7 +154,8 @@ TerminalSession* bridge_create(int widthPx,
                                std::move(callbacks),
                                backend,
                                palette,
-                               std::move(sshOptions));
+                               std::move(sshOptions),
+                               shell);
 }
 
 void bridge_destroy(TerminalSession* session)
diff --git a/src/contour_macos/SessionBridge.h b/src/contour_macos/SessionBridge.h
index e3c34fde..f5a85562 100644
--- a/src/contour_macos/SessionBridge.h
+++ b/src/contour_macos/SessionBridge.h
@@ -86,7 +86,8 @@ TerminalSession* bridge_create(int widthPx,
                                BridgeCallbacks const& callbacks,
                                int renderMode,
                                char const* themeName,
-                               BridgeSshConfig const& ssh);
+                               BridgeSshConfig const& ssh,
+                               char const* shellOverride);
 
 /// Returns the render backend the session was created with (a BridgeRenderMode value). The view
 /// uses this to choose its presentation path (CGImage blit vs GL context present).
diff --git a/src/contour_macos/TerminalSession.cpp b/src/contour_macos/TerminalSession.cpp
index 238e9197..7533c393 100644
--- a/src/contour_macos/TerminalSession.cpp
+++ b/src/contour_macos/TerminalSession.cpp
@@ -47,9 +47,12 @@ namespace
         return settings;
     }
 
-    [[nodiscard]] std::unique_ptr<vtpty::Pty> makeLocalPty(vtbackend::PageSize pageSize)
+    [[nodiscard]] std::unique_ptr<vtpty::Pty> makeLocalPty(vtbackend::PageSize pageSize,
+                                                           std::string const& shellOverride)
     {
-        auto const shell = vtpty::Process::loginShell(false).front();
+        // Use the configured shell if one was set, else the account's login shell (pw_shell — the
+        // engine's loginShell() does not consult $SHELL).
+        auto const shell = !shellOverride.empty() ? shellOverride : vtpty::Process::loginShell(false).front();
         auto exe = vtpty::Process::ExecInfo {};
         exe.program = shell;
         exe.workingDirectory = vtpty::Process::homeDirectory();
@@ -60,10 +63,11 @@ namespace
     [[nodiscard]] std::unique_ptr<vtpty::Pty> makePty(
         vtbackend::PageSize pageSize,
         TerminalSession::SshOptions const& ssh,
+        std::string const& shellOverride,
         std::function<bool(std::string, int, std::string)> const& verifyHostkey)
     {
         if (ssh.host.empty())
-            return makeLocalPty(pageSize);
+            return makeLocalPty(pageSize, shellOverride);
 
 #if defined(VTPTY_LIBSSH2)
         auto const home = vtpty::Process::homeDirectory();
@@ -89,7 +93,7 @@ namespace
         return std::make_unique<vtpty::SshSession>(config, onHostkey);
 #else
         (void) verifyHostkey;
-        return makeLocalPty(pageSize);
+        return makeLocalPty(pageSize, shellOverride);
 #endif
     }
 
@@ -161,7 +165,8 @@ TerminalSession::TerminalSession(vtbackend::PageSize pageSize,
                                  Callbacks callbacks,
                                  RenderBackend backend,
                                  vtbackend::ColorPalette colorPalette,
-                                 SshOptions ssh):
+                                 SshOptions ssh,
+                                 std::string shellOverride):
     _callbacks { std::move(callbacks) },
     _pageSize { pageSize },
     _surfaceSize { surfaceSize },
@@ -205,7 +210,7 @@ TerminalSession::TerminalSession(vtbackend::PageSize pageSize,
 
     _terminal = std::make_unique<vtbackend::Terminal>(
         *this,
-        makePty(_pageSize, ssh, _callbacks.verifySshHostkey),
+        makePty(_pageSize, ssh, shellOverride, _callbacks.verifySshHostkey),
         makeSettings(_pageSize),
         steady_clock::now());
 
diff --git a/src/contour_macos/TerminalSession.h b/src/contour_macos/TerminalSession.h
index 5a0339db..415d74e2 100644
--- a/src/contour_macos/TerminalSession.h
+++ b/src/contour_macos/TerminalSession.h
@@ -77,7 +77,8 @@ class TerminalSession: public vtbackend::Terminal::Events
                     Callbacks callbacks,
                     RenderBackend backend,
                     vtbackend::ColorPalette colorPalette,
-                    SshOptions ssh);
+                    SshOptions ssh,
+                    std::string shellOverride);
     ~TerminalSession() override;
 
     /// Spawns the shell and starts the read/parse thread.
diff --git a/src/contour_macos/TerminalView.h b/src/contour_macos/TerminalView.h
index 3f9c9c29..b99d65b9 100644
--- a/src/contour_macos/TerminalView.h
+++ b/src/contour_macos/TerminalView.h
@@ -29,7 +29,8 @@
            fontSize:(double)fontSize
          renderMode:(int)renderMode
               theme:(NSString*)theme
-     sshDestination:(NSString*)sshDestination;
+     sshDestination:(NSString*)sshDestination
+              shell:(NSString*)shell;
 
 // Requests a repaint on the main thread (safe to call from any thread).
 - (void)requestRedraw;
diff --git a/src/contour_macos/TerminalView.mm b/src/contour_macos/TerminalView.mm
index b291cfc6..49f83b1b 100644
--- a/src/contour_macos/TerminalView.mm
+++ b/src/contour_macos/TerminalView.mm
@@ -186,6 +186,7 @@ int cbVerifySshHostkey(void* userData, char const* host, int port, char const* f
          renderMode:(int)renderMode
               theme:(NSString*)theme
      sshDestination:(NSString*)sshDestination
+              shell:(NSString*)shell
 {
     self = [super initWithFrame:frame];
     if (!self)
@@ -270,7 +271,8 @@ int cbVerifySshHostkey(void* userData, char const* host, int port, char const* f
                                             callbacks,
                                             renderMode,
                                             [theme UTF8String],
-                                            ssh);
+                                            ssh,
+                                            [shell length] ? [shell UTF8String] : NULL);
     if (!_session)
     {
         [self release];
diff --git a/src/contour_macos/main.mm b/src/contour_macos/main.mm
index d08b0259..fc74a3b1 100644
--- a/src/contour_macos/main.mm
+++ b/src/contour_macos/main.mm
@@ -32,29 +32,55 @@
     [window setTitle:@"contour"];
     [window setReleasedWhenClosed:YES];
 
-    // Render backend selector: CONTOUR_RENDER=gl selects the OpenGL backend, anything else (or
-    // unset) the CPU backend. A single env var is enough until the preferences UI exists; it lets
-    // the GL path be exercised without a rebuild. BridgeRender_CPU/_OpenGL are 0/1.
-    int renderMode = 0; // BridgeRender_CPU
-    char const* renderEnv = getenv("CONTOUR_RENDER");
-    if (renderEnv && (strcmp(renderEnv, "gl") == 0 || strcmp(renderEnv, "opengl") == 0))
-        renderMode = 1; // BridgeRender_OpenGL
-
-    // Color theme selector: CONTOUR_THEME=light selects the light theme, anything else (or unset)
-    // the dark theme. Like CONTOUR_RENDER, a single env var suffices until the preferences UI exists.
+    // Settings resolution order (highest priority first): environment variable, then the persisted
+    // NSUserDefaults value, then the built-in default. NSUserDefaults lives in
+    // ~/Library/Preferences/org.contourterminal.Contour.plist and is editable with the `defaults`
+    // CLI (e.g. `defaults write org.contourterminal.Contour fontFamily Monaco`) — the persistence
+    // backing a future preferences UI, with the env vars kept as an override for quick testing.
+    NSUserDefaults* defaults = [NSUserDefaults standardUserDefaults];
+
+    // Theme: env CONTOUR_THEME, else defaults "theme", else "dark".
+    // (Explicit ternaries rather than the `?:` GNU shorthand, which is not standard C++.)
     char const* themeEnv = getenv("CONTOUR_THEME");
-    NSString* theme = (themeEnv && strcmp(themeEnv, "light") == 0) ? @"light" : @"dark";
+    NSString* themeDefault = [defaults stringForKey:@"theme"];
+    NSString* theme = themeEnv ? [NSString stringWithUTF8String:themeEnv]
+                               : (themeDefault ? themeDefault : @"dark");
+    if (![theme isEqualToString:@"light"])
+        theme = @"dark";
 
-    // SSH destination: CONTOUR_SSH=user@host[:port] opens an SSH session instead of a local shell.
+    // Render backend: env CONTOUR_RENDER, else defaults "renderer", else "cpu".
+    char const* renderEnv = getenv("CONTOUR_RENDER");
+    NSString* rendererDefault = [defaults stringForKey:@"renderer"];
+    NSString* renderStr = renderEnv ? [NSString stringWithUTF8String:renderEnv]
+                                    : (rendererDefault ? rendererDefault : @"cpu");
+    int renderMode =
+        ([renderStr isEqualToString:@"gl"] || [renderStr isEqualToString:@"opengl"]) ? 1 : 0;
+
+    // Font family/size: defaults "fontFamily"/"fontSize", else Menlo/14. (No env override needed.)
+    NSString* fontFamilyDefault = [defaults stringForKey:@"fontFamily"];
+    NSString* fontFamily = fontFamilyDefault ? fontFamilyDefault : @"Menlo";
+    double fontSize = [defaults doubleForKey:@"fontSize"];
+    if (fontSize <= 0.0)
+        fontSize = 14.0;
+
+    // SSH destination: env CONTOUR_SSH, else defaults "ssh". Empty/absent = local shell.
     char const* sshEnv = getenv("CONTOUR_SSH");
-    NSString* sshDestination = (sshEnv && sshEnv[0] != '\0') ? [NSString stringWithUTF8String:sshEnv] : nil;
+    NSString* sshDestination = sshEnv ? [NSString stringWithUTF8String:sshEnv]
+                                      : [defaults stringForKey:@"ssh"];
+    if (![sshDestination length])
+        sshDestination = nil;
+
+    // Shell: defaults "shell", else nil = the account's login shell. (env $SHELL is not consulted —
+    // set defaults "shell" or leave it for the login shell.)
+    NSString* shell = [defaults stringForKey:@"shell"];
 
     TerminalView* view = [[TerminalView alloc] initWithFrame:frame
-                                                  fontFamily:@"Menlo"
-                                                    fontSize:14.0
+                                                  fontFamily:fontFamily
+                                                    fontSize:fontSize
                                                   renderMode:renderMode
                                                        theme:theme
-                                              sshDestination:sshDestination];
+                                              sshDestination:sshDestination
+                                                       shell:shell];
     if (view)
     {
         [window setContentView:view];
