diff --git a/.gitignore b/.gitignore index d6a8c743..b1cda65b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ rootshell/Resources/rootshellvpn.app/ # Developer ID provisioning profiles (account-specific, place locally) scripts/*.provisionprofile .DS_Store + +# Local zmx test environment: Linux zmx binaries and an SSH private key. +docs/zmx-testenv/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ab7f1053..afa0436f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to the rootshell app for iPhone, iPad, Vision Pro, and Mac, newest first. Versions are listed as `release-build`, matching the version shown in Settings, About. +## Unreleased + +### zmx + +- **zmx Session Discovery:** Connecting to a host running zmx now lists its sessions in the session picker, with the attached-client count, when each was created, any labels you have set, and a live preview of the session's screen. Tapping one attaches to it. Sessions whose socket has gone stale are left out, and hosts that merely have zmx installed stay quiet — the picker only appears when there is a real session to offer. Works against zmx 0.7.0 and newer. +- **Auto-Start zmx:** The per-connection "Auto-start multiplexer" option gains zmx alongside Off, tmux, tmux -CC and herdr. zmx auto-start attaches to your session or creates it on first use, works under ssh, mosh and tssh, and syncs across devices. Settings gains a zmx discovery toggle and an Auto-Start page for the session name and an optional custom command, with a preview of the exact command that will run. + +### Multiplexers + +- **Consistent Multiplexer Icons:** The Multiplexers settings screen and the session picker drew different icons for the same multiplexer — tmux and zellij had theirs swapped between the two screens. Each multiplexer now has one icon used everywhere. + ## 1.0.11-137 - August 23, 2026 ### Mosh diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 2c849f80..c1582287 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -1707,6 +1707,515 @@ } } }, + "%lld clients" : { + "comment" : "zmx attached client count", + "localizations" : { + "ar" : { + "variations" : { + "plural" : { + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld عميل" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "عميل واحد" + } + }, + "two" : { + "stringUnit" : { + "state" : "translated", + "value" : "عميلان" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld عملاء" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld عميلًا" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld عميل" + } + } + } + } + }, + "ca" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld client" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld clients" + } + } + } + } + }, + "cs" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klient" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klienti" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klienta" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klientů" + } + } + } + } + }, + "da" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klient" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klienter" + } + } + } + } + }, + "de" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Client" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Clients" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld cliente" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld clientes" + } + } + } + } + }, + "fi" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld asiakas" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld asiakasta" + } + } + } + } + }, + "fr" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld client" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld clients" + } + } + } + } + }, + "he" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "לקוח אחד" + } + }, + "two" : { + "stringUnit" : { + "state" : "translated", + "value" : "שני לקוחות" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld לקוחות" + } + } + } + } + }, + "hu" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kliens" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kliens" + } + } + } + } + }, + "it" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld client" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld client" + } + } + } + } + }, + "ja" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldクライアント" + } + } + } + } + }, + "ko" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "클라이언트 %lld개" + } + } + } + } + }, + "nb" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klient" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klienter" + } + } + } + } + }, + "nl" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld client" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld clients" + } + } + } + } + }, + "pl" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klient" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klienci" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klientów" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klienta" + } + } + } + } + }, + "pt-BR" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld cliente" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld clientes" + } + } + } + } + }, + "pt-PT" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld cliente" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld clientes" + } + } + } + } + }, + "ro" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld client" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld clienți" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld de clienți" + } + } + } + } + }, + "sl" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld odjemalec" + } + }, + "two" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld odjemalca" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld odjemalci" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld odjemalcev" + } + } + } + } + }, + "sv" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klient" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klienter" + } + } + } + } + }, + "uk" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld клієнт" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld клієнти" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld клієнтів" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld клієнта" + } + } + } + } + }, + "vi" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld máy khách" + } + } + } + } + }, + "zh-Hans" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 个客户端" + } + } + } + } + }, + "zh-Hant" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 個用戶端" + } + } + } + } + } + } + }, "-/%llu" : { "localizations" : { "ar" : { @@ -2170,6 +2679,2015 @@ } } }, + "Attach to or create zmx session \"%@\" on connect" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الاتصال بجلسة zmx \"%@\" أو إنشاؤها عند الاتصال" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connecta o crea la sessió zmx \"%@\" en connectar" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Připojit se k relaci zmx \"%@\" nebo ji vytvořit při připojení" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilslut eller opret zmx-session \"%@\" ved forbindelse" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-Sitzung \"%@\" beim Verbinden anhängen oder erstellen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectar o crear sesión zmx \"%@\" al conectar" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liity zmx-istuntoon \"%@\" tai luo se yhdistettäessä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se connecter à la session zmx \"%@\" ou la créer lors de la connexion" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "התחברות או יצירת הפעלת zmx \"%@\" בעת חיבור" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Csatlakozás a(z) \"%@\" zmx-munkamenethez, vagy létrehozása kapcsolódáskor" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Collega o crea la sessione zmx \"%@\" alla connessione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "接続時にzmxセッション「%@」に接続または作成" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결 시 zmx 세션 \"%@\"에 연결하거나 생성" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Koble til eller opprett zmx-økt \"%@\" ved tilkobling" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbinden met of zmx-sessie \"%@\" aanmaken bij verbinding" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podłącz do sesji zmx \"%@\" lub utwórz ją przy połączeniu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectar ou criar sessão zmx \"%@\" ao conectar" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ligar ou criar sessão zmx \"%@\" ao ligar" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectare la sau creare sesiune zmx \"%@\" la conectare" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pripni se na sejo zmx \"%@\" ali jo ustvari ob povezavi" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anslut till eller skapa zmx-session \"%@\" vid anslutning" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Приєднатися до сеансу zmx \"%@\" або створити його при підключенні" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối hoặc tạo phiên zmx \"%@\" khi kết nối" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "连接时附加到或创建 zmx 会话「%@」" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "連線時附加到或建立 zmx 工作階段「%@」" + } + } + } + }, + "Created %@" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أُنشئت %@" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creada %@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vytvořeno %@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oprettet %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erstellt %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creada %@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luotu %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Créée %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נוצר %@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Létrehozva %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creata %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "作成 %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "생성됨 %@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opprettet %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aangemaakt %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utworzono %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Criada %@" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Criada %@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creată %@" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ustvarjeno %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skapad %@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Створено %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã tạo %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建于 %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "建立於 %@" + } + } + }, + "comment" : "zmx session age, e.g. 'Created 2 hr. ago'" + }, + "Discover zmx Sessions" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "اكتشاف جلسات zmx" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descobreix sessions d'zmx" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zjistit relace zmx" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opdag zmx-sessioner" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-Sitzungen entdecken" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descubrir sesiones de zmx" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etsi zmx-istuntoja" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Découvrir les sessions zmx" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "גלה סשנים של zmx" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-munkamenetek felderítése" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scopri sessioni zmx" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmxセッションを検出" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 세션 검색" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oppdag zmx-økter" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-sessies ontdekken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wykryj sesje zmx" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descobrir sessões zmx" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descobrir sessões zmx" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descoperă sesiuni zmx" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odkrij seje zmx" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Identifiera zmx-sessioner" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виявити сеанси zmx" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khám phá phiên zmx" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发现 zmx 会话" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "探索 zmx 工作階段" + } + } + } + }, + "Multiplexer Tips" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "نصائح معددات الطرفية" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consells de multiplexors" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipy pro multiplexery" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiplekser-tip" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiplexer-Tipps" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consejos de multiplexores" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiplekservinkit" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conseils multiplexeurs" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "טיפים למרבבים" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiplexer tippek" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consigli sui multiplexer" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "マルチプレクサのヒント" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "멀티플렉서 팁" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiplekser-tips" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiplexer-tips" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wskazówki multiplekserów" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dicas de multiplexadores" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sugestões de multiplexadores" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sfaturi multiplexoare" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nasveti za multiplekserje" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiplexertips" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поради щодо мультиплексорів" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mẹo bộ ghép kênh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "终端复用器提示" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "終端多工器提示" + } + } + } + }, + "Overrides the entire zmx command, including the session name. Leave empty to use the default." : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يتجاوز أمر zmx بالكامل، بما في ذلك اسم الجلسة. اتركه فارغًا لاستخدام الأمر الافتراضي." + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Substitueix tota l'ordre d'zmx, inclòs el nom de la sessió. Deixa-ho buit per utilitzar el valor per defecte." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nahradí celý příkaz zmx včetně názvu relace. Ponechte prázdné pro použití výchozího." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilsidesætter hele zmx-kommandoen, inklusive sessionsnavnet. Lad feltet stå tomt for at bruge standarden." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Überschreibt den gesamten zmx-Befehl, einschließlich des Sitzungsnamens. Leer lassen, um den Standard zu verwenden." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sustituye todo el comando de zmx, incluido el nombre de la sesión. Déjalo vacío para usar el predeterminado." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Korvaa koko zmx-komennon, myös istunnon nimen. Jätä tyhjäksi käyttääksesi oletusta." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remplace toute la commande zmx, y compris le nom de session. Laissez vide pour utiliser la valeur par défaut." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "עוקף את כל פקודת zmx, כולל שם ההפעלה. השאר ריק כדי להשתמש בברירת המחדל." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Felülírja a teljes zmx-parancsot, a munkamenet nevével együtt. Hagyja üresen az alapértelmezett használatához." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sostituisce l'intero comando zmx, incluso il nome della sessione. Lascia vuoto per usare il valore predefinito." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "セッション名を含め、zmxコマンド全体を上書きします。空欄にするとデフォルトを使用します。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세션 이름을 포함한 전체 zmx 명령어를 대체합니다. 비워 두면 기본값을 사용합니다." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overstyrer hele zmx-kommandoen, inkludert øktnavnet. La feltet stå tomt for å bruke standarden." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overschrijft de volledige zmx-opdracht, inclusief de sessienaam. Laat leeg om de standaard te gebruiken." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zastępuje całe polecenie zmx, łącznie z nazwą sesji. Pozostaw puste, aby użyć domyślnego." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Substitui todo o comando do zmx, incluindo o nome da sessão. Deixe vazio para usar o padrão." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Substitui todo o comando do zmx, incluindo o nome da sessão. Deixe vazio para usar a predefinição." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Înlocuiește întreaga comandă zmx, inclusiv numele sesiunii. Lăsați gol pentru a folosi valoarea implicită." + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nadomesti celoten ukaz zmx, vključno z imenom seje. Pustite prazno za privzeto vrednost." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ersätter hela zmx-kommandot, inklusive sessionsnamnet. Lämna tomt för att använda standardvärdet." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Замінює всю команду zmx, зокрема назву сеансу. Залиште порожнім, щоб використати типову." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ghi đè toàn bộ lệnh zmx, bao gồm cả tên phiên. Để trống để dùng giá trị mặc định." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "覆盖整条 zmx 命令,包括会话名称。留空则使用默认值。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "覆寫整道 zmx 指令,包括工作階段名稱。留空則使用預設值。" + } + } + } + }, + "The zmx command used when auto-start is enabled on a connection." : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أمر zmx المستخدَم عند تفعيل البدء التلقائي في اتصال." + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'ordre d'zmx que s'utilitza quan l'inici automàtic està activat en una connexió." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Příkaz zmx použitý, když je pro připojení zapnuté automatické spuštění." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den zmx-kommando, der bruges, når automatisk start er slået til for en forbindelse." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der zmx-Befehl, der verwendet wird, wenn Autostart für eine Verbindung aktiviert ist." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El comando de zmx que se usa cuando el inicio automático está activado en una conexión." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-komento, jota käytetään, kun automaattinen käynnistys on käytössä yhteydessä." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La commande zmx utilisée lorsque le démarrage automatique est activé sur une connexion." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פקודת zmx המשמשת כאשר הפעלה אוטומטית מופעלת בחיבור." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "A zmx-parancs, amelyet az automatikus indítás használ egy kapcsolatnál." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il comando zmx usato quando l'avvio automatico è abilitato su una connessione." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "接続で自動起動が有効なときに使用されるzmxコマンドです。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결에서 자동 시작이 켜져 있을 때 사용되는 zmx 명령어입니다." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-kommandoen som brukes når automatisk start er slått på for en tilkobling." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De zmx-opdracht die wordt gebruikt wanneer automatisch starten is ingeschakeld voor een verbinding." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Polecenie zmx używane, gdy dla połączenia włączony jest automatyczny start." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O comando do zmx usado quando o início automático está ativado em uma conexão." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "O comando do zmx utilizado quando o arranque automático está ativado numa ligação." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comanda zmx folosită când pornirea automată este activată pentru o conexiune." + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukaz zmx, ki se uporabi, ko je za povezavo vklopljen samodejni zagon." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-kommandot som används när automatisk start är aktiverad för en anslutning." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Команда zmx, що використовується, коли для з'єднання ввімкнено автозапуск." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lệnh zmx được dùng khi bật khởi động tự động cho một kết nối." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在连接上启用自动启动时所使用的 zmx 命令。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在連線上啟用自動啟動時所使用的 zmx 指令。" + } + } + } + }, + "The zmx session name used by auto-start. Defaults to \"main\" if empty." : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "اسم جلسة zmx المستخدم بواسطة التشغيل التلقائي. القيمة الافتراضية \"main\" إذا كان فارغًا." + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "El nom de la sessió zmx utilitzat per l'inici automàtic. Per defecte és \"main\" si està buit." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Název relace zmx používaný automatickým spuštěním. Výchozí hodnota je \"main\", pokud je prázdný." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tmux-sessionsnavnet brugt af autostart. Standard er \"main\", hvis tomt." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der von Autostart verwendete zmx-Sitzungsname. Standard ist \"main\", wenn leer." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El nombre de sesión zmx usado por el inicio automático. Por defecto es \"main\" si está vacío." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automaattisen käynnistyksen käyttämä zmx-istunnon nimi. Oletusarvo on \"main\", jos tyhjä." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le nom de session zmx utilisé par le démarrage automatique. Par défaut \"main\" si vide." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שם הפעלת zmx המשמש הפעלה אוטומטית. ברירת המחדל היא \"main\" אם ריק." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Az automatikus indítás által használt zmx-munkamenet neve. Alapértelmezés: \"main\", ha üres." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il nome della sessione zmx usato dall'avvio automatico. Predefinito: \"main\" se vuoto." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "自動起動で使用されるzmxセッション名。空の場合、デフォルトは「main」です。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "자동 시작에서 사용하는 zmx 세션 이름. 비어 있으면 기본값은 \"main\"입니다." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-øktnavnet brukt av autostart. Standard er \"main\" hvis tomt." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De zmx-sessienaam gebruikt door automatisch starten. Standaard \"main\" indien leeg." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nazwa sesji zmx używana przez automatyczne uruchamianie. Domyślnie \"main\", jeśli puste." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O nome da sessão zmx usado pelo início automático. O padrão é \"main\" se vazio." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "O nome da sessão zmx utilizado pelo arranque automático. Predefinição: \"main\" se vazio." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Numele sesiunii zmx utilizat de pornirea automată. Implicit \"main\" dacă este gol." + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ime seje zmx, ki ga uporablja samodejni zagon. Privzeto je \"main\", če je prazno." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tmux-sessionsnamnet som används av autostart. Standard är \"main\" om tomt." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Назва сеансу zmx для автозапуску. За замовчуванням \"main\", якщо порожнє." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tên phiên zmx được sử dụng bởi tự động khởi động. Mặc định là \"main\" nếu để trống." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动启动使用的 zmx 会话名称。为空时默认为「main」。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "自動啟動使用的 zmx 工作階段名稱。為空時預設為「main」。" + } + } + } + }, + "This command runs when zmx auto-start is enabled on a connection. zmx attaches to the session if it is running, or starts it." : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يتم تشغيل هذا الأمر عند تمكين التشغيل التلقائي لـ zmx على الاتصال. يرتبط zmx بالجلسة إذا كانت قيد التشغيل، أو يبدؤها." + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aquesta ordre s'executa quan l'inici automàtic d'zmx està habilitat en una connexió. zmx es connecta a la sessió si s'està executant, o la inicia." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tento příkaz se spustí, když je na připojení zapnuto automatické spuštění zmx. zmx se připojí k relaci, pokud běží, nebo ji spustí." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Denne kommando køres, når zmx-autostart er aktiveret på en forbindelse. zmx tilslutter sessionen, hvis den kører, eller starter den." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dieser Befehl wird ausgeführt, wenn zmx-Autostart für eine Verbindung aktiviert ist. zmx hängt sich an die Sitzung an, falls sie läuft, oder startet sie." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Este comando se ejecuta cuando el inicio automático de zmx está habilitado en una conexión. zmx se conecta a la sesión si está en ejecución, o la inicia." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tämä komento suoritetaan, kun zmx-automaattikäynnistys on käytössä yhteydellä. zmx liittyy istuntoon, jos se on käynnissä, tai käynnistää sen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cette commande s'exécute lorsque le démarrage automatique de zmx est activé sur une connexion. zmx se connecte à la session si elle est en cours d'exécution, sinon il la démarre." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פקודה זו מופעלת כאשר הפעלה אוטומטית של zmx מופעלת בחיבור. zmx מתחבר להפעלה אם היא פועלת, או מפעיל אותה." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ez a parancs fut, amikor a zmx automatikus indítás engedélyezve van egy kapcsolaton. A zmx csatlakozik a munkamenethez, ha az fut, vagy elindítja azt." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Questo comando viene eseguito quando l'avvio automatico di zmx è abilitato su una connessione. zmx si collega alla sessione se è in esecuzione, altrimenti la avvia." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このコマンドは、接続でzmx自動起動が有効になっている場合に実行されます。zmxはセッションが実行中であればそのセッションに接続し、実行中でなければ起動します。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 명령은 연결에서 zmx 자동 시작이 활성화된 경우 실행됩니다. zmx는 세션이 실행 중이면 연결하고, 실행 중이 아니면 시작합니다." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Denne kommandoen kjøres når zmx-autostart er aktivert på en tilkobling. zmx kobler til økten hvis den kjører, eller starter den." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dit commando wordt uitgevoerd wanneer zmx automatisch starten is ingeschakeld op een verbinding. zmx verbindt met de sessie als deze actief is, of start deze." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "To polecenie jest uruchamiane, gdy automatyczne uruchamianie zmx jest włączone dla połączenia. zmx podłącza się do sesji, jeśli jest uruchomiona, lub ją uruchamia." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Este comando é executado quando o início automático do zmx está habilitado em uma conexão. O zmx conecta à sessão se ela estiver em execução ou a inicia." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Este comando é executado quando o arranque automático do zmx está ativado numa ligação. O zmx liga-se à sessão se esta estiver em execução ou inicia-a." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Această comandă rulează când pornirea automată zmx este activată pe o conexiune. zmx se atașează la sesiune dacă aceasta rulează sau o pornește." + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ta ukaz se izvede, ko je samodejni zagon zmx omogočen na povezavi. zmx se pripne na sejo, če ta teče, sicer jo zažene." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detta kommando körs när zmx-autostart är aktiverat på en anslutning. zmx ansluter till sessionen om den körs, annars startas den." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ця команда виконується, коли автозапуск zmx увімкнено для з'єднання. zmx приєднується до сеансу, якщо він запущений, або запускає його." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lệnh này chạy khi tự động khởi động zmx được bật trên kết nối. zmx sẽ kết nối vào phiên nếu phiên đang chạy, hoặc khởi động phiên đó." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当连接上启用 zmx 自动启动时,将运行此命令。如果会话正在运行,zmx 会附加到该会话,否则会启动它。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "當連線上啟用 zmx 自動啟動時,將執行此指令。若工作階段正在執行,zmx 會附加到該工作階段,否則會啟動它。" + } + } + } + }, + "zmx" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + } + } + }, + "zmx Auto-Start" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "البدء التلقائي لـ zmx" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inici automàtic d'zmx" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatické spuštění zmx" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisk start af zmx" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-Autostart" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inicio automático de zmx" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmxin automaattinen käynnistys" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Démarrage automatique de zmx" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הפעלה אוטומטית של zmx" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx automatikus indítás" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avvio automatico di zmx" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx自動起動" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 자동 시작" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisk start av zmx" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx automatisch starten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatyczny start zmx" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Início Automático do zmx" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arranque Automático do zmx" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pornire automată zmx" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Samodejni zagon zmx" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisk start av zmx" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автозапуск zmx" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khởi động tự động zmx" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 自动启动" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 自動啟動" + } + } + } + }, + "zmx attaches automatically" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يرتبط zmx تلقائيًا" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx es connecta automàticament" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx se připojuje automaticky" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx tilslutter automatisk" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx hängt sich automatisch an" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx se conecta automáticamente" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx liittyy automaattisesti" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx s'attache automatiquement" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx מתחבר אוטומטית" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "A zmx automatikusan csatlakozik" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx si collega automaticamente" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx自動接続" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 자동 연결" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx kobler til automatisk" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx verbindt automatisch" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx podłącza się automatycznie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx conecta automaticamente" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx liga-se automaticamente" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx se atașează automat" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx se pripne samodejno" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx ansluter automatiskt" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx приєднується автоматично" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx tự động kết nối" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 自动附加" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 自動附加" + } + } + } + }, + "zmx enabled, launch command configured" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx مُفعَّل، تم تكوين أمر التشغيل" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx activat, ordre d'inici configurada" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx povolen, spouštěcí příkaz nakonfigurován" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx aktiveret, startkommando konfigureret" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx aktiviert, Startbefehl konfiguriert" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx activado, comando de inicio configurado" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx käytössä, käynnistyskomento määritetty" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx activé, commande de lancement configurée" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx מופעל, פקודת הפעלה מוגדרת" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx engedélyezve, indítási parancs konfigurálva" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx attivato, comando di avvio configurato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx有効、起動コマンド設定済み" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 활성화됨, 실행 명령 설정됨" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx aktivert, startkommando konfigurert" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx ingeschakeld, startcommando geconfigureerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx włączony, polecenie uruchomieniowe skonfigurowane" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx ativado, comando de inicialização configurado" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx ativado, comando de lançamento configurado" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx activat, comandă de lansare configurată" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx omogočen, zagonski ukaz nastavljen" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx aktiverat, startkommando konfigurerat" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx увімкнено, команду запуску налаштовано" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã bật zmx, đã cấu hình lệnh khởi chạy" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 已启用,启动命令已配置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 已啟用,啟動命令已設定" + } + } + } + }, + "zmx reads a name starting with '-' as an option. This name falls back to the global default." : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يقرأ zmx الاسم الذي يبدأ بـ '-' كخيار. سيعود هذا الاسم إلى الإعداد الافتراضي العام." + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx interpreta un nom que comença amb '-' com una opció. Aquest nom recorre al valor global per defecte." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx čte název začínající znakem '-' jako volbu. Tento název se vrátí ke globální výchozí hodnotě." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx læser et navn, der begynder med '-', som en indstilling. Dette navn falder tilbage til den globale standard." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx liest einen Namen, der mit '-' beginnt, als Option. Dieser Name fällt auf die globale Vorgabe zurück." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx reads a name starting with '-' as an option. This name falls back to the global default." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx interpreta un nombre que empieza por '-' como una opción. Este nombre recurre al valor global predeterminado." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx tulkitsee '-'-merkillä alkavan nimen valitsimeksi. Tämä nimi palautuu yleiseen oletusarvoon." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx interprète un nom commençant par '-' comme une option. Ce nom revient à la valeur globale par défaut." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "‏zmx קורא שם שמתחיל ב-'-' כאפשרות. שם זה חוזר לברירת המחדל הגלובלית." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "A zmx a '-' karakterrel kezdődő nevet kapcsolóként értelmezi. Ez a név a globális alapértelmezésre vált vissza." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx interpreta un nome che inizia con '-' come un'opzione. Questo nome torna al valore predefinito globale." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx は '-' で始まる名前をオプションとして解釈します。この名前はグローバルのデフォルトに戻ります。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx는 '-'로 시작하는 이름을 옵션으로 인식합니다. 이 이름은 전역 기본값으로 대체됩니다." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx leser et navn som starter med '-', som et valg. Dette navnet faller tilbake til den globale standarden." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx leest een naam die met '-' begint als een optie. Deze naam valt terug op de algemene standaard." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx odczytuje nazwę zaczynającą się od '-' jako opcję. Ta nazwa powróci do domyślnej wartości globalnej." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O zmx interpreta um nome iniciado por '-' como uma opção. Esse nome retorna ao padrão global." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "O zmx interpreta um nome iniciado por '-' como uma opção. Este nome regressa à predefinição global." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx interpretează un nume care începe cu '-' ca opțiune. Acest nume revine la valoarea implicită globală." + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx ime, ki se začne z '-', prebere kot možnost. To ime se vrne na globalno privzeto vrednost." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx tolkar ett namn som börjar med '-' som en flagga. Det här namnet återgår till den globala standarden." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx читає ім'я, що починається з '-', як параметр. Це ім'я повертається до глобального значення за замовчуванням." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx đọc tên bắt đầu bằng '-' như một tùy chọn. Tên này sẽ quay lại giá trị mặc định chung." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 会将以“-”开头的名称视为选项。此名称将回退到全局默认值。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 會將以「-」開頭的名稱視為選項。此名稱將回復為全域預設值。" + } + } + } + }, "—" : { "localizations" : { "ar" : { @@ -71937,6 +74455,161 @@ } } }, + "Auto-start zmx on the remote host" : { + "comment" : "Mosh help: --zmx option\nSSH help: --zmx option\nTrzsz help: --zmx option", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "بدء zmx تلقائيًا على المضيف البعيد" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Iniciar zmx automàticament a l'amfitrió remot" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automaticky spustit zmx na vzdáleném hostiteli" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start zmx automatisk på fjernværten" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx automatisch auf dem Remote-Host starten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Iniciar zmx automáticamente en el host remoto" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Käynnistä zmx automaattisesti etäisännässä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Démarrer zmx automatiquement sur l'hôte distant" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הפעלה אוטומטית של zmx במארח המרוחק" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "A zmx automatikus indítása a távoli gazdagépen" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avvia automaticamente zmx sull'host remoto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リモートホストで zmx を自動起動" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "원격 호스트에서 zmx 자동 시작" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start zmx automatisk på fjernverten" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx automatisch starten op de externe host" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatycznie uruchom zmx na zdalnym hoście" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Iniciar o zmx automaticamente no host remoto" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Iniciar o zmx automaticamente no anfitrião remoto" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pornește automat zmx pe gazda la distanță" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Samodejno zaženi zmx na oddaljenem gostitelju" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Starta zmx automatiskt på fjärrvärden" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автоматично запускати zmx на віддаленому хості" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tự động khởi động zmx trên máy chủ từ xa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在远程主机上自动启动 zmx" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在遠端主機上自動啟動 zmx" + } + } + } + }, "Automatic" : { "comment" : "Appearance mode: follow system setting\nHole-punch mode: automatic detection\nVNC security preference", "localizations" : { @@ -510308,6 +512981,166 @@ } } }, + "The zmx session this profile attaches to or creates on connect. Leave on the global default to use the session name from Settings. Names may use letters, numbers, '.', '_' and '-', and may not start with '-'." : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "جلسة zmx التي يتصل بها هذا الملف الشخصي أو ينشئها عند الاتصال. اتركه على الافتراضي العام لاستخدام اسم الجلسة من الإعدادات. يمكن أن تحتوي الأسماء على أحرف وأرقام و'.' و'_' و'-'، ولا يمكن أن تبدأ بـ '-'." + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "La sessió zmx a la qual aquest perfil es connecta o que crea en connectar-se. Deixeu-ho al valor global per defecte per utilitzar el nom de sessió de la configuració. Els noms poden fer servir lletres, xifres, '.', '_' i '-', i no poden començar amb '-'." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relace zmx, ke které se tento profil při připojení připojí nebo kterou vytvoří. Ponechte globální výchozí hodnotu, chcete-li použít název relace z Nastavení. Názvy mohou obsahovat písmena, číslice, '.', '_' a '-' a nesmí začínat znakem '-'." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den zmx-session, som denne profil tilsluttes eller opretter ved forbindelse. Behold den globale standard for at bruge sessionsnavnet fra Indstillinger. Navne må bruge bogstaver, tal, '.', '_' og '-' og må ikke begynde med '-'." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die zmx-Sitzung, mit der sich dieses Profil beim Verbinden verbindet oder die es erstellt. Belasse die globale Vorgabe, um den Sitzungsnamen aus den Einstellungen zu verwenden. Namen dürfen Buchstaben, Ziffern, '.', '_' und '-' enthalten und nicht mit '-' beginnen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The zmx session this profile attaches to or creates on connect. Leave on the global default to use the session name from Settings. Names may use letters, numbers, '.', '_' and '-', and may not start with '-'." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La sesión de zmx a la que este perfil se conecta o que crea al conectar. Déjalo en el valor global predeterminado para usar el nombre de sesión de Ajustes. Los nombres pueden usar letras, números, '.', '_' y '-', y no pueden empezar por '-'." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-istunto, johon tämä profiili yhdistää tai jonka se luo yhdistettäessä. Jätä yleiseen oletusarvoon käyttääksesi asetusten istuntonimeä. Nimissä voi käyttää kirjaimia, numeroita sekä merkkejä '.', '_' ja '-', eivätkä ne saa alkaa merkillä '-'." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La session zmx à laquelle ce profil se connecte ou qu'il crée à la connexion. Laissez la valeur globale par défaut pour utiliser le nom de session des Réglages. Les noms peuvent contenir des lettres, des chiffres, '.', '_' et '-', et ne peuvent pas commencer par '-'." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "‏הפעלת zmx שאליה פרופיל זה מתחבר או שהוא יוצר בעת ההתחברות. השאר את ברירת המחדל הגלובלית כדי להשתמש בשם ההפעלה מההגדרות. שמות יכולים לכלול אותיות, ספרות, '.', '_' ו-'-', ואינם יכולים להתחיל ב-'-'." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "A zmx-munkamenet, amelyhez ez a profil csatlakozik, vagy amelyet létrehoz csatlakozáskor. Hagyd a globális alapértelmezésen a Beállítások munkamenetnevének használatához. A nevek betűket, számjegyeket, '.', '_' és '-' karaktereket tartalmazhatnak, és nem kezdődhetnek '-' karakterrel." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La sessione zmx a cui questo profilo si connette o che crea alla connessione. Lascia il valore predefinito globale per usare il nome sessione dalle Impostazioni. I nomi possono usare lettere, numeri, '.', '_' e '-', e non possono iniziare con '-'." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このプロファイルが接続時に接続または作成する zmx セッションです。設定のセッション名を使うにはグローバルのデフォルトのままにします。名前には英数字と '.'、'_'、'-' を使用でき、'-' で始めることはできません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 프로파일이 연결 시 연결하거나 생성하는 zmx 세션입니다. 설정의 세션 이름을 사용하려면 전역 기본값으로 두십시오. 이름에는 문자, 숫자, '.', '_', '-'를 사용할 수 있으며 '-'로 시작할 수 없습니다." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-økten denne profilen kobler til eller oppretter ved tilkobling. La den stå på den globale standarden for å bruke øktnavnet fra Innstillinger. Navn kan bruke bokstaver, tall, '.', '_' og '-', og kan ikke starte med '-'." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De zmx-sessie waarmee dit profiel verbinding maakt of die het aanmaakt bij verbinden. Laat op de algemene standaard staan om de sessienaam uit Instellingen te gebruiken. Namen mogen letters, cijfers, '.', '_' en '-' bevatten en mogen niet met '-' beginnen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sesja zmx, z którą ten profil się łączy lub którą tworzy przy połączeniu. Pozostaw wartość domyślną globalną, aby użyć nazwy sesji z Ustawień. Nazwy mogą zawierać litery, cyfry, '.', '_' i '-' oraz nie mogą zaczynać się od '-'." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "A sessão zmx à qual este perfil se conecta ou que cria ao conectar. Deixe no padrão global para usar o nome de sessão dos Ajustes. Os nomes podem usar letras, números, '.', '_' e '-', e não podem começar com '-'." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "A sessão zmx à qual este perfil se liga ou que cria ao ligar. Deixe na predefinição global para usar o nome de sessão das Definições. Os nomes podem usar letras, números, '.', '_' e '-', e não podem começar por '-'." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sesiunea zmx la care se conectează acest profil sau pe care o creează la conectare. Lasă valoarea implicită globală pentru a folosi numele sesiunii din Configurări. Numele pot folosi litere, cifre, '.', '_' și '-' și nu pot începe cu '-'." + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seja zmx, s katero se ta profil poveže ali jo ustvari ob povezavi. Pustite globalno privzeto vrednost, da uporabite ime seje iz Nastavitev. Imena lahko vsebujejo črke, števke, '.', '_' in '-' ter se ne smejo začeti z '-'." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-sessionen som den här profilen ansluter till eller skapar vid anslutning. Låt den stå på den globala standarden för att använda sessionsnamnet från Inställningar. Namn kan använda bokstäver, siffror, '.', '_' och '-', och får inte börja med '-'." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сеанс zmx, до якого цей профіль підключається або який створює під час підключення. Залиште глобальне значення за замовчуванням, щоб використати назву сеансу з Параметрів. Назви можуть містити літери, цифри, '.', '_' і '-' та не можуть починатися з '-'." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phiên zmx mà hồ sơ này kết nối tới hoặc tạo khi kết nối. Giữ nguyên mặc định chung để dùng tên phiên từ Cài đặt. Tên có thể dùng chữ cái, chữ số, '.', '_' và '-', và không được bắt đầu bằng '-'." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此配置在连接时接入或创建的 zmx 会话。保持全局默认值可使用“设置”中的会话名称。名称可使用字母、数字、“.”、“_”和“-”,且不能以“-”开头。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此描述檔在連線時接入或建立的 zmx 工作階段。保持全域預設值可使用「設定」中的工作階段名稱。名稱可使用字母、數字、「.」、「_」和「-」,且不能以「-」開頭。" + } + } + } + }, "The VNC stream itself is unencrypted, but every byte travels inside the SSH tunnel." : { "localizations" : { "ar" : { @@ -525745,160 +528578,6 @@ } } }, - "tmux Tips" : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "نصائح tmux" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Consells de tmux" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tipy pro tmux" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmux-tip" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmux-Tipps" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Consejos de tmux" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmux-vinkit" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Conseils tmux" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "טיפים ל-tmux" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmux tippek" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Consigli su tmux" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmuxのヒント" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmux 팁" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmux-tips" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmux-tips" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wskazówki tmux" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dicas do tmux" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sugestões do tmux" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sfaturi tmux" - } - }, - "sl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nasveti za tmux" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmux-tips" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Поради щодо tmux" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mẹo tmux" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmux 提示" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmux 提示" - } - } - } - }, "tmux window" : { "localizations" : { "ar" : { diff --git a/README.md b/README.md index cf46ee3c..7bf7820b 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,8 @@ brew install --cask rootshell - **Multipath TCP** - MPTCP over Apple Network.framework maintains subflows on WiFi and cellular simultaneously for near-instant handover (requires Linux 5.6+ on server) - **Native SCP & SFTP** - Built-in `scp` and interactive `sftp` client with tab completion, glob patterns, and real-time progress - **Background SSH Tunnels** - Maintain port forwards without a terminal UI with auto-start on launch and byte transfer statistics -- **Auto-start tmux** - Automatically attach to or create tmux sessions on connect -- **tmux Session Discovery** - After connecting, lists active tmux sessions with window count and live terminal preview +- **Auto-start Multiplexer** - Automatically attach to or create a tmux, herdr or zmx session on connect +- **Multiplexer Session Discovery** - After connecting, lists active tmux, zellij, herdr and zmx sessions with per-multiplexer detail and a live terminal preview - **Tailscale Integration** - Device discovery and SSH to your tailnet with no-auth support - **Host Shorthand (HSS)** - Pattern-based hostname expansion with YAML configuration - **Connection Health** - Real-time RTT and packet loss tracking with time series chart and negotiated cryptographic algorithm details diff --git a/rootshell/Core/CloudKit/CloudKitSyncable.swift b/rootshell/Core/CloudKit/CloudKitSyncable.swift index d1fc1aed..0eff0b25 100644 --- a/rootshell/Core/CloudKit/CloudKitSyncable.swift +++ b/rootshell/Core/CloudKit/CloudKitSyncable.swift @@ -159,8 +159,14 @@ extension SSHConnectionHistoryEntry: CloudKitSyncable { // device which members the writer knew. A nil member means the user // cleared it only when the stamped version is at least the version that // introduced that member. + // zmxAutoEnable rides the envelope rather than getting its own record + // field like `herdrAutoEnable` above. That inconsistency is deliberate: + // a new top-level field costs a production CloudKit schema deploy, which + // an outside contributor cannot perform, and the envelope exists + // precisely so later fields do not. let envelope = HistoryExtensionPayload(terminalType: terminalType, - multiplexerSessionName: multiplexerSessionName) + multiplexerSessionName: multiplexerSessionName, + zmxAutoEnable: zmxAutoEnable) if let envelopeData = try? JSONEncoder().encode(envelope) { record["extensionData"] = envelopeData } else { @@ -280,6 +286,7 @@ extension SSHConnectionHistoryEntry: CloudKitSyncable { tmuxAutoEnable: tmuxAutoEnable, tmuxAutoMode: tmuxAutoMode, herdrAutoEnable: herdrAutoEnable, + zmxAutoEnable: extensionPayload?.zmxAutoEnable, launchCommand: launchCommand, launchCommandMode: launchCommandMode, terminalType: extensionPayload?.terminalType, diff --git a/rootshell/Core/Persistence/SerializableConnectionConfig.swift b/rootshell/Core/Persistence/SerializableConnectionConfig.swift index e8180dd0..c2620ae3 100644 --- a/rootshell/Core/Persistence/SerializableConnectionConfig.swift +++ b/rootshell/Core/Persistence/SerializableConnectionConfig.swift @@ -157,6 +157,7 @@ nonisolated struct SerializableConnectionConfig: Codable, Equatable, Sendable { /// herdr auto-attach. Optional for backward compat — older serialized /// sessions decode as nil and restore as disabled. let herdrAutoEnable: Bool? + let zmxAutoEnable: Bool? let launchCommand: String? let launchCommandMode: SSHConfig.LaunchCommandMode? /// Per-connection TERM override. Optional for backward compat — older @@ -194,6 +195,7 @@ nonisolated struct SerializableConnectionConfig: Codable, Equatable, Sendable { self.tmuxAutoEnable = config.tmuxAutoEnable self.tmuxAutoMode = config.tmuxAutoMode self.herdrAutoEnable = config.herdrAutoEnable + self.zmxAutoEnable = config.zmxAutoEnable self.launchCommand = config.launchCommand self.launchCommandMode = config.launchCommandMode self.terminalType = config.terminalType @@ -275,6 +277,7 @@ nonisolated struct SerializableConnectionConfig: Codable, Equatable, Sendable { config.tmuxAutoEnable = tmuxAutoEnable ?? false config.tmuxAutoMode = tmuxAutoMode ?? .regular config.herdrAutoEnable = herdrAutoEnable ?? false + config.zmxAutoEnable = zmxAutoEnable ?? false config.launchCommand = launchCommand config.launchCommandMode = launchCommandMode ?? .afterConnect config.terminalType = terminalType diff --git a/rootshell/Core/Terminal/Reconnect/TerminalConnectionHistoryRecorder.swift b/rootshell/Core/Terminal/Reconnect/TerminalConnectionHistoryRecorder.swift index 42295c9f..adfe5a5c 100644 --- a/rootshell/Core/Terminal/Reconnect/TerminalConnectionHistoryRecorder.swift +++ b/rootshell/Core/Terminal/Reconnect/TerminalConnectionHistoryRecorder.swift @@ -101,6 +101,7 @@ final class TerminalConnectionHistoryRecorder { tmuxAutoEnable: sshConfig.tmuxAutoEnable, tmuxAutoMode: sshConfig.tmuxAutoMode, herdrAutoEnable: sshConfig.herdrAutoEnable, + zmxAutoEnable: sshConfig.zmxAutoEnable, launchCommand: sshConfig.launchCommand, launchCommandMode: sshConfig.launchCommandMode, terminalType: sshConfig.terminalType, diff --git a/rootshell/Core/Terminal/WorkingDirectoryURI.swift b/rootshell/Core/Terminal/WorkingDirectoryURI.swift new file mode 100644 index 00000000..7396bf5e --- /dev/null +++ b/rootshell/Core/Terminal/WorkingDirectoryURI.swift @@ -0,0 +1,41 @@ +// +// WorkingDirectoryURI.swift +// rootshell +// +// Decoding for the working directory a terminal reports over OSC 7. +// + +import Foundation + +/// Turns an OSC 7 working-directory value into a plain filesystem path. +/// +/// OSC 7 carries `file://`. Ghostty hands the +/// sequence's payload through `GHOSTTY_ACTION_PWD` as it arrived, so a pane +/// whose shell reports the URI form receives it still encoded, and a directory +/// with a space in it would otherwise surface as `/home/dave/web%20app`. +/// +/// A value that is not a `file://` URI is returned untouched. That case is not +/// an error: a bare path may legitimately contain spaces or a literal `%`, and +/// running one through a percent-decoder corrupts it. +nonisolated enum WorkingDirectoryURI { + /// The decoded path, plus the host when the value carried one. + /// + /// The host matters because OSC 7 crosses SSH boundaries: a session that + /// ssh'd elsewhere reports a directory that does not exist on the machine + /// reading it. Callers that need to resolve the path locally check it; + /// callers that only display the directory can ignore it. + static func decode(_ value: String) -> (path: String, host: String?) { + guard value.hasPrefix("file://") else { return (value, nil) } + guard let components = URLComponents(string: value), !components.path.isEmpty else { + // Malformed URI: showing the raw value beats showing nothing. + return (value, nil) + } + let host = components.host.flatMap { $0.isEmpty ? nil : $0 } + return (components.path, host) + } + + /// `decode` for callers that only need the path. + static func path(_ value: String) -> String { + decode(value).path + } +} diff --git a/rootshell/Features/LocalShell/Commands/LocalShellSession+Help.swift b/rootshell/Features/LocalShell/Commands/LocalShellSession+Help.swift index 053f0c37..fabc4d25 100644 --- a/rootshell/Features/LocalShell/Commands/LocalShellSession+Help.swift +++ b/rootshell/Features/LocalShell/Commands/LocalShellSession+Help.swift @@ -207,7 +207,7 @@ Use this after editing the file from a local shell or through a symlink in your let examplesHeader = String(localized: "Examples:", comment: "Command help: examples section header") let helpText = """ -usage: ssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr] [--path] [-o option] destination [command] +usage: ssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr|--zmx] [--path] [-o option] destination [command] \(optionsHeader) -p port \(String(localized: "Connect to this port (default: 22)", comment: "SSH help: -p option")) @@ -218,6 +218,7 @@ usage: ssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--he -AA \(String(localized: "Enable SSH agent forwarding with auto-approval", comment: "SSH help: -AA option")) --tmux \(String(localized: "Auto-start tmux on the remote host", comment: "SSH help: --tmux option")) --herdr \(String(localized: "Auto-start herdr on the remote host", comment: "SSH help: --herdr option")) + --zmx \(String(localized: "Auto-start zmx on the remote host", comment: "SSH help: --zmx option")) --path \(String(localized: "Prepend Rootshell's PATH wrapper for remote exec commands", comment: "SSH help: --path option")) -o option \(String(localized: "Set SSH option (Port, User, ProxyJump, IdentityFile)", comment: "SSH help: -o option")) @@ -250,6 +251,7 @@ usage: ssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--he ssh -AA user@host ssh --tmux user@host ssh --herdr user@host + ssh --zmx user@host ssh --path user@host wish serve ssh user@host ls -la /tmp ssh user@host "echo hello" @@ -399,8 +401,8 @@ usage: sftp [-P port] [-i identity] [-J jump_host] [-o option] [user@]host let examplesHeader = String(localized: "Examples:", comment: "Command help: examples section header") let helpText = """ -usage: roam [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr] [--predict mode] [--predict-overwrite] destination - mosh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr] [--predict mode] [--predict-overwrite] destination +usage: roam [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr|--zmx] [--predict mode] [--predict-overwrite] destination + mosh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr|--zmx] [--predict mode] [--predict-overwrite] destination \(optionsHeader) -p port \(String(localized: "Connect to this port (default: 22)", comment: "Mosh help: -p option")) @@ -411,6 +413,7 @@ usage: roam [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--h -AA \(String(localized: "Enable SSH agent forwarding with auto-approval", comment: "Mosh help: -AA option")) --tmux \(String(localized: "Auto-start tmux on the remote host", comment: "Mosh help: --tmux option")) --herdr \(String(localized: "Auto-start herdr on the remote host", comment: "Mosh help: --herdr option")) + --zmx \(String(localized: "Auto-start zmx on the remote host", comment: "Mosh help: --zmx option")) --predict mode \(String(localized: "Prediction mode: always, adaptive, never (default: always)", comment: "Mosh help: --predict option")) --predict-overwrite \(String(localized: "Replace existing cells when predicting input", comment: "Mosh help: --predict-overwrite option")) --no-predict-overwrite \(String(localized: "Use inserting predictions for this session", comment: "Mosh help: --no-predict-overwrite option")) @@ -442,6 +445,7 @@ usage: roam [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--h roam -J bastion user@internal mosh --tmux user@host mosh --herdr user@host + mosh --zmx user@host """ Task { @MainActor [weak self] in @@ -544,8 +548,8 @@ usage: ssh-copy-id [-fnp] [-i identity] [-t target] [-o option] [user@]hostname let examplesHeader = String(localized: "Examples:", comment: "Command help: examples section header") let helpText = """ -usage: tssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr] [--quic|--kcp] destination - trzsz [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr] [--quic|--kcp] destination +usage: tssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr|--zmx] [--quic|--kcp] destination + trzsz [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr|--zmx] [--quic|--kcp] destination \(optionsHeader) -p port \(String(localized: "Connect to this port (default: 22)", comment: "Trzsz help: -p option")) @@ -556,6 +560,7 @@ usage: tssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--h -AA \(String(localized: "Enable SSH agent forwarding with auto-approval", comment: "Trzsz help: -AA option")) --tmux \(String(localized: "Auto-start tmux on the remote host", comment: "Trzsz help: --tmux option")) --herdr \(String(localized: "Auto-start herdr on the remote host", comment: "Trzsz help: --herdr option")) + --zmx \(String(localized: "Auto-start zmx on the remote host", comment: "Trzsz help: --zmx option")) --quic \(String(localized: "Force QUIC transport mode", comment: "Trzsz help: --quic option")) --kcp \(String(localized: "Force KCP transport mode", comment: "Trzsz help: --kcp option")) --server path \(String(localized: "Path to tssh server on remote", comment: "Trzsz help: --server option")) @@ -586,6 +591,7 @@ usage: tssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--h trzsz -J bastion user@internal tssh --tmux user@host tssh --herdr user@host + tssh --zmx user@host """ Task { @MainActor [weak self] in diff --git a/rootshell/Features/LocalShell/LocalShellSession+EmbeddedSessions.swift b/rootshell/Features/LocalShell/LocalShellSession+EmbeddedSessions.swift index 5dc23005..59f8dab4 100644 --- a/rootshell/Features/LocalShell/LocalShellSession+EmbeddedSessions.swift +++ b/rootshell/Features/LocalShell/LocalShellSession+EmbeddedSessions.swift @@ -95,6 +95,7 @@ extension LocalShellSession { tmuxAutoEnable: config.tmuxAutoEnable, tmuxAutoMode: config.tmuxAutoMode, herdrAutoEnable: config.herdrAutoEnable, + zmxAutoEnable: config.zmxAutoEnable, remoteCommand: config.remoteCommand, remoteCommandPolicy: config.remoteCommandPolicy, multiplexerSessionName: config.multiplexerSessionName @@ -279,6 +280,7 @@ extension LocalShellSession { tmuxAutoEnable: config.tmuxAutoEnable, tmuxAutoMode: config.tmuxAutoMode, herdrAutoEnable: config.herdrAutoEnable, + zmxAutoEnable: config.zmxAutoEnable, launchCommand: config.launchCommand, launchCommandMode: config.launchCommandMode, terminalType: config.terminalType, diff --git a/rootshell/Features/Multiplexer/Expose/HerdrExposeAdapter.swift b/rootshell/Features/Multiplexer/Expose/HerdrExposeAdapter.swift index 86d1cbce..80eac158 100644 --- a/rootshell/Features/Multiplexer/Expose/HerdrExposeAdapter.swift +++ b/rootshell/Features/Multiplexer/Expose/HerdrExposeAdapter.swift @@ -33,7 +33,7 @@ nonisolated struct HerdrExposeAdapter: MultiplexerExposeAdapter { return MuxScript.wrap(body, nonce: nonce) } - func parseTick(output: String, nonce: String) -> MuxTickResult? { + func parseTick(output: String, session _: String?, nonce: String) -> MuxTickResult? { let sections = MuxScript.sections(of: output, nonce: nonce) guard sections.found, !sections.unsupported else { return nil } guard let root = MuxScript.json(sections.topology) as? [String: Any] else { return nil } diff --git a/rootshell/Features/Multiplexer/Expose/MultiplexerExposeAdapter.swift b/rootshell/Features/Multiplexer/Expose/MultiplexerExposeAdapter.swift index c46dd10b..72690ad6 100644 --- a/rootshell/Features/Multiplexer/Expose/MultiplexerExposeAdapter.swift +++ b/rootshell/Features/Multiplexer/Expose/MultiplexerExposeAdapter.swift @@ -29,9 +29,21 @@ nonisolated protocol MultiplexerExposeAdapter: Sendable { func tickScript(session: String?, request: MuxTickRequest, nonce: String) -> String /// nil when the multiplexer is unusable here (too old, no session). - func parseTick(output: String, nonce: String) -> MuxTickResult? + /// + /// `session` is the one the pane is attached to. Only zmx reads it: its + /// cells are the host's sessions rather than one session's windows, so + /// which cell is current is not something its tick output reports. + func parseTick(output: String, session: String?, nonce: String) -> MuxTickResult? func focusScript(session: String?, tabID: String) -> String + + /// Floor for the feed's tick pacing. Defaulted for multiplexers whose + /// listing is cheap; raised by ones where it is not. + var minInterval: TimeInterval { get } +} + +nonisolated extension MultiplexerExposeAdapter { + var minInterval: TimeInterval { 0 } } /// Marker framing and shell quoting shared by the adapters. diff --git a/rootshell/Features/Multiplexer/Expose/MultiplexerExposeFeed.swift b/rootshell/Features/Multiplexer/Expose/MultiplexerExposeFeed.swift index dec2f1b0..0e9a5306 100644 --- a/rootshell/Features/Multiplexer/Expose/MultiplexerExposeFeed.swift +++ b/rootshell/Features/Multiplexer/Expose/MultiplexerExposeFeed.swift @@ -100,6 +100,7 @@ final class MultiplexerExposeFeed { case .tmux: mux = "tmux" case .zellij: mux = "zellij" case .herdr: mux = "herdr" + case .zmx: mux = "zmx" case nil: mux = "" } if let sessionName, !sessionName.isEmpty { return "\(mux) · \(sessionName)" } @@ -108,13 +109,54 @@ final class MultiplexerExposeFeed { // MARK: - Eligibility - /// The multiplexer this terminal is attached to, once it owns the screen. - /// `hasOwnedAltScreen` is maintained by agent attention's scans, which - /// may not have run yet; the surface's own alt-screen state stands in. + /// The multiplexer this terminal is attached to. + /// + /// Two slots, checked in order. `rawMultiplexer` holds a multiplexer that + /// owns the screen, and is admitted once it actually has: + /// `hasOwnedAltScreen` is maintained by agent attention's scans, which may + /// not have run yet, so the surface's own alt-screen state stands in. + /// + /// `passthroughMultiplexer` holds one that does not own the screen, and so + /// is admitted without that gate — a passthrough could never pass it. The + /// program running inside the passthrough may hold the alternate screen, + /// or nothing may: a zmx pane sitting at a shell prompt is indistinguishable + /// from a plain SSH pane by screen state alone, which is exactly when its + /// page is most wanted. The identity is exact and known at attach time + /// instead of being inferred. + /// + /// Order matters, and the raw slot wins. Both can be set at once — a + /// connection configured to start zmx and to run tmux inside it sets each + /// — and in that case the surface really is showing tmux's composite, so + /// tmux's windows are the honest page. (id=agent-attention-raw-mux) static func binding(for terminal: Ghostty.TerminalView?) -> Ghostty.TerminalView.RawMultiplexerBinding? { - guard let terminal, let binding = terminal.rawMultiplexer, RemoteExecProbe.canProbe(terminal), - binding.hasOwnedAltScreen || isAlternateScreenActive(terminal) else { return nil } - return binding + guard let terminal, RemoteExecProbe.canProbe(terminal) else { return nil } + if let binding = terminal.rawMultiplexer, adapter(for: binding.type) != nil, + binding.hasOwnedAltScreen || isAlternateScreenActive(terminal) { + return binding + } + if let binding = terminal.passthroughMultiplexer, adapter(for: binding.type) != nil { + return binding + } + return nil + } + + /// The adapter serving `type`, or nil when the exposé cannot serve it. + /// + /// Reported here rather than at the two use sites so `binding(for:)` and + /// `configure` cannot disagree, and so `start` returns false for an + /// unserved pane instead of `run` mistaking a nil adapter for "not yet + /// detected" and spending a probe to rediscover it. + /// + /// Every case is served today. The optional stays because the next + /// multiplexer may not be, and because that is what keeps an unserved type + /// out of `binding(for:)` in one place. + static func adapter(for type: MultiplexerType) -> (any MultiplexerExposeAdapter)? { + switch type { + case .herdr: HerdrExposeAdapter() + case .tmux: TmuxExposeAdapter() + case .zellij: ZellijExposeAdapter() + case .zmx: ZmxExposeAdapter() + } } /// A pane with no binding whose screen is taken by something: ask the @@ -151,7 +193,7 @@ final class MultiplexerExposeFeed { func start(terminal: Ghostty.TerminalView) -> Bool { let binding = Self.binding(for: terminal) guard binding != nil || Self.canDetect(terminal) else { - Self.logger.debug("not a multiplexer pane: bound=\(terminal.rawMultiplexer != nil) probe=\(RemoteExecProbe.canProbe(terminal)) alt=\(Self.isAlternateScreenActive(terminal))") + Self.logger.debug("not a multiplexer pane: bound=\(terminal.rawMultiplexer != nil) passthrough=\(terminal.passthroughMultiplexer != nil) probe=\(RemoteExecProbe.canProbe(terminal)) alt=\(Self.isAlternateScreenActive(terminal))") return false } Self.logger.debug("start: \(binding?.type.rawValue ?? "detect", privacy: .public)") @@ -182,6 +224,11 @@ final class MultiplexerExposeFeed { return true } + /// Fast end of the pacing range for the adapter in hand. + private var floorInterval: TimeInterval { + max(Self.baseInterval, adapter?.minInterval ?? 0) + } + private func resetSession() { frames = [:] snapshot = nil @@ -200,11 +247,8 @@ final class MultiplexerExposeFeed { private func configure(_ binding: Ghostty.TerminalView.RawMultiplexerBinding) { type = binding.type sessionName = binding.sessionName - switch binding.type { - case .herdr: adapter = HerdrExposeAdapter() - case .tmux: adapter = TmuxExposeAdapter() - case .zellij: adapter = ZellijExposeAdapter() - } + adapter = Self.adapter(for: binding.type) + interval = floorInterval // Quick re-entry: repaint the last picture while the first tick runs. if let terminal, let cache, cache.owner == ObjectIdentifier(terminal), cache.session == "\(binding.type.rawValue):\(binding.sessionName ?? "")", @@ -268,6 +312,7 @@ final class MultiplexerExposeFeed { for attempt in 0.. MuxTickResult? { + func parseTick(output: String, session _: String?, nonce: String) -> MuxTickResult? { let sections = MuxScript.sections(of: output, nonce: nonce) guard sections.found, !sections.unsupported else { return nil } let rows = sections.topology.split(separator: "\n").compactMap(row) diff --git a/rootshell/Features/Multiplexer/Expose/ZellijExposeAdapter.swift b/rootshell/Features/Multiplexer/Expose/ZellijExposeAdapter.swift index 898f9681..5e7b6a14 100644 --- a/rootshell/Features/Multiplexer/Expose/ZellijExposeAdapter.swift +++ b/rootshell/Features/Multiplexer/Expose/ZellijExposeAdapter.swift @@ -45,7 +45,7 @@ nonisolated struct ZellijExposeAdapter: MultiplexerExposeAdapter { return MuxScript.wrap(body, nonce: nonce) } - func parseTick(output: String, nonce: String) -> MuxTickResult? { + func parseTick(output: String, session _: String?, nonce: String) -> MuxTickResult? { let sections = MuxScript.sections(of: output, nonce: nonce) guard sections.found, !sections.unsupported else { return nil } let halves = sections.topology.components(separatedBy: "::MX_PANES::") diff --git a/rootshell/Features/Multiplexer/Expose/ZmxExposeAdapter.swift b/rootshell/Features/Multiplexer/Expose/ZmxExposeAdapter.swift new file mode 100644 index 00000000..e1e1448c --- /dev/null +++ b/rootshell/Features/Multiplexer/Expose/ZmxExposeAdapter.swift @@ -0,0 +1,223 @@ +// +// ZmxExposeAdapter.swift +// rootshell +// +// zmx is the odd one out. The other three adapters project ONE session's +// windows; zmx has none -- a session is exactly one PTY, with no windows, +// tabs or splits -- so this one projects the HOST's sessions instead, one +// exposé cell per session, and picking a cell switches the pane to it. +// +// That reading is not a liberty taken with the feature. `zmx attach` run +// with `ZMX_SESSION` set does not create a client: it opens the CURRENT +// session's socket and sends `.Switch `, which the daemon hands to +// its leader client, which resets terminal modes and reattaches in place +// (zmx `main.zig` `switchSesh` / `loop.zig` `handleSwitch`). The pane keeps +// its PTY and carries its cwd across. Crucially the sender need not be the +// pane, so `focusScript` drives it over the exec channel like every other +// adapter's focus rather than through the detach key. +// +// It is also the only way in: `SessionPickerOverlay` runs once at connect and +// is suppressed outright by zmx auto-start, and an attached pane cannot type +// its way to another session because stdin belongs to the inner shell. +// + +import Foundation + +nonisolated struct ZmxExposeAdapter: MultiplexerExposeAdapter { + var type: MultiplexerType { .zmx } + + /// `zmx list` probes every session socket serially and prints nothing until + /// the last one answers, so a tick costs a good deal more than the window + /// list the other three ask for. Pace it accordingly rather than letting + /// the shared 0.4s floor apply. (See `SessionDiscoveryRunner` for the fuller + /// account of that serialisation.) + var minInterval: TimeInterval { 1.5 } + + /// Session names are the exposé's tab ids, so no `ZMX_SESSION` is exported + /// on the listing calls: with it set, `zmx list` prefixes the caller's own + /// row with an arrow instead of two spaces, and this adapter would then + /// depend on that marker where it already knows the attached session by + /// name. `ZmxDiscoveryParser` strips both, but not having to care is better. + /// + /// `ZMX_SESSION_PREFIX=` on every call is not cosmetic: `zmx list` reports + /// real socket names while every other subcommand prepends that variable, + /// so an exported prefix would point `history` at a different session. The + /// same neutralisation is applied in `SessionDiscoveryRunner`, + /// `TerminalView+Session.attachToSession` and `SSHConfig.zmxExecCommandLine`. + private static let prefix = "ZMX_SESSION_PREFIX= " + + /// Rows of scrollback pulled per session. + /// + /// `zmx history --vt` replays the WHOLE buffer, prelude first: modes, + /// scrolling region and keyboard state, then every row. Tailing it is not + /// just a size bound, it is what makes the capture safe to paint -- the + /// prelude would otherwise apply the session's own modes to the preview + /// surface. Cutting at a line boundary cannot sever an SGR sequence, which + /// is why this is `tail -n` and not `tail -c`. + private static let captureRows = 80 + + // MARK: - Scripts + + func resolveSessionScript(nonce: String) -> String { + // `--short` prints bare names and skips unreachable sessions, which are + // not attachable and would make a lone live session look ambiguous. + // The feed accepts the answer only when exactly one name comes back. + MuxScript.wrap( + "echo \(MuxScript.dq(MuxScript.topology(nonce)))" + + "; \(Self.prefix)zmx list --short 2>/dev/null", + nonce: nonce + ) + } + + func tickScript(session: String?, request: MuxTickRequest, nonce: String) -> String { + var body = "command -v zmx >/dev/null 2>&1 || echo \(MuxScript.dq(MuxScript.unsupportedMarker))" + // Same budget shape as discovery: no tight timeout on the list, which + // emits nothing until every probe finishes and so would cost the full + // wall clock AND return nothing; a short one per capture, where a + // truncation costs one preview rather than the whole tick. + body += "; _zt=\"\"; command -v timeout >/dev/null 2>&1 && _zt=\"timeout 2\"" + body += "; echo \(MuxScript.dq(MuxScript.topology(nonce)))" + // `::SESSIONS::` so `ZmxDiscoveryParser.parse` reads this verbatim. One + // parser for the picker and the exposé: it already handles the row + // prefix, error rows, labels, both `cwd=` spellings and v0.7.0's + // `start_dir=`, and it has the fixtures. + body += "; echo \"::SESSIONS::\"" + body += "; \(Self.prefix)${_zt:+timeout 5} zmx list 2>/dev/null" + for name in request.fetch { + body += "; \(MuxScript.paneMarker(nonce: nonce, id: name))" + body += "; \(Self.prefix)$_zt zmx history \(MuxScript.dq(name)) --vt 2>/dev/null" + body += " | tail -n \(Self.captureRows)" + // `zmx history --vt` ends mid-line: its last bytes are a cursor + // position escape, not a newline (tmux's `capture-pane` terminates + // its output, which is why it never needed this). `MuxScript.sections` + // matches markers with `hasPrefix` on whole lines, so without this + // the NEXT pane marker is appended to the capture's final line and + // stops being a marker at all -- every section after the first is + // swallowed into the first one's body and those cells never get a + // frame. `visibleScreen` trims trailing newlines, so the extra one + // costs nothing when the capture already ended cleanly. + body += "; echo" + } + return MuxScript.wrap(body, nonce: nonce) + } + + /// Switches the pane from `session` to `tabID`. + /// + /// `ZMX_SESSION` is exported explicitly rather than inherited, which is the + /// whole mechanism: zmx reads it to decide that this is a switch and not a + /// new client (README, "nested sessions"). Without it `zmx attach` would try + /// to ATTACH on this exec channel, which has no terminal -- so the empty + /// guard below is a safety property, not tidiness. + func focusScript(session: String?, tabID: String) -> String { + guard let session, !session.isEmpty, session != tabID else { + // No current session to switch away from, or the cell already + // showing. `switchSesh` would bounce the pane through a detach and + // reattach to reach where it already is. + return MuxScript.wrap("true", nonce: "focus") + } + return MuxScript.wrap( + "ZMX_SESSION=\(MuxScript.dq(session)) \(Self.prefix)zmx attach \(MuxScript.dq(tabID))", + nonce: "focus" + ) + } + + // MARK: - Parsing + + func parseTick(output: String, session: String?, nonce: String) -> MuxTickResult? { + let sections = MuxScript.sections(of: output, nonce: nonce) + guard sections.found, !sections.unsupported else { return nil } + + let sessions = ZmxDiscoveryParser.parse(output: sections.topology) + guard !sessions.isEmpty else { return nil } + + var frames: [String: MuxPaneFrame] = [:] + for section in sections.panes { + let screen = Self.visibleScreen(section.body) + guard !screen.isEmpty else { continue } + frames[section.id] = MuxPaneFrame( + // SGR state is threaded across the whole capture and the tail + // cut the opening of it; close it so one session's colour + // cannot bleed past its own cell. + ansi: screen + "\u{1B}[0m", + cursor: nil, + revision: MuxExposeIdentity.contentRevision(screen) + ) + } + + let tabs = sessions.enumerated().map { index, info in + MuxTab( + id: info.name, + index: index, + title: info.name, + isActive: info.name == session, + cols: Self.grid.cols, + rows: Self.grid.rows, + panes: [ + MuxPane( + id: info.name, + rect: MuxCellRect(x: 0, y: 0, width: Self.grid.cols, height: Self.grid.rows), + isActive: true, + isPreviewable: true, + title: info.command + ) + ], + badge: Self.badge(for: info) + ) + } + + return MuxTickResult( + snapshot: MuxExposeSnapshot(tabs: tabs, activeTabID: session), + frames: frames, + unchanged: [], + truncated: sections.truncated + ) + } + + // MARK: - Geometry + + /// The cell grid every zmx cell claims. + /// + /// Fixed, and that is a decision rather than a stub. zmx reports no + /// geometry anywhere -- not in `list`, not in `version` -- and the one + /// place a size appears, the scrolling region in the `--vt` prelude, is + /// exactly what the tail removes. So the capture text is the only witness, + /// and measuring it is not stable: the feed captures visible cells every + /// tick and hidden ones every third, so a measured grid would change size + /// whenever a cell went unfetched, and `MuxTab` being Equatable that would + /// read as a topology change and rebuild the tray on almost every tick. + /// + /// A constant is safe in the direction that matters. The preview surface + /// grows until it holds `cols` x `rows` and paints nothing before it fits, + /// so claiming more than the session uses costs slack around the content + /// while claiming less wraps every row. This is deliberately on the + /// generous side of the usual 80x24. + /// + /// Worth revisiting against a real host: see O6 in docs/zmx-expose-plan.md. + private static let grid = (cols: 100, rows: 30) + + /// The capture reduced to what a screen would show. + /// + /// A full-screen program clears before drawing, so the bytes after the last + /// erase-display are that program's screen and everything before it is + /// scrollback. Without a clear the tail already stands in for the screen. + static func visibleScreen(_ capture: String) -> String { + var text = capture + if let lastClear = text.range(of: "\u{1B}[2J", options: .backwards) { + text = String(text[lastClear.upperBound...]) + } + while text.hasPrefix("\n") || text.hasPrefix("\r") { text.removeFirst() } + while text.hasSuffix("\n") || text.hasSuffix("\r") { text.removeLast() } + return text + } + + /// Trailing caption note. The client count is the one fact worth surfacing: + /// zmx routes a switch to the session's LEADER client, so a session another + /// terminal also holds may move that terminal instead of this pane. + private static func badge(for info: ZmxSessionInfo) -> String? { + guard let clients = info.clientCount, clients > 1 else { return nil } + return String( + format: String(localized: "%d clients", comment: "zmx exposé cell badge, attached client count"), + clients + ) + } +} diff --git a/rootshell/Features/Profiles/ConnectionProfileManager.swift b/rootshell/Features/Profiles/ConnectionProfileManager.swift index b35d9e14..61383096 100644 --- a/rootshell/Features/Profiles/ConnectionProfileManager.swift +++ b/rootshell/Features/Profiles/ConnectionProfileManager.swift @@ -242,6 +242,7 @@ final class ConnectionProfileManager { sshConfig.tmuxAutoEnable = historyEntry.tmuxAutoEnable ?? false sshConfig.tmuxAutoMode = historyEntry.tmuxAutoMode ?? .regular sshConfig.herdrAutoEnable = historyEntry.herdrAutoEnable ?? false + sshConfig.zmxAutoEnable = historyEntry.zmxAutoEnable ?? false sshConfig.launchCommand = historyEntry.launchCommand sshConfig.launchCommandMode = historyEntry.launchCommandMode ?? .afterConnect sshConfig.terminalType = historyEntry.terminalType diff --git a/rootshell/Features/Profiles/Views/ProfileEditorSheet.swift b/rootshell/Features/Profiles/Views/ProfileEditorSheet.swift index 62be6f77..5798ee39 100644 --- a/rootshell/Features/Profiles/Views/ProfileEditorSheet.swift +++ b/rootshell/Features/Profiles/Views/ProfileEditorSheet.swift @@ -92,6 +92,7 @@ struct ProfileEditorSheet: View { // herdr auto-attach (mutually exclusive with enableTmux via the picker) @State private var enableHerdr: Bool = false + @State private var enableZmx: Bool = false // Globals the multiplexer captions fall back to. Observed rather than read // directly so the captions refresh when Settings change; the values @@ -1305,15 +1306,17 @@ struct ProfileEditorSheet: View { connectionProtocol == .mosh ? .regular : tmuxAutoMode } - /// Bridges the stored `(enableTmux, tmuxAutoMode, enableHerdr)` fields to a + /// Bridges the stored `(enableTmux, tmuxAutoMode, enableHerdr, enableZmx)` fields to a /// single Picker. Control mode can't run over Mosh, so it's presented as /// regular there. private var tmuxLaunchSelection: Binding { Binding( - get: { TmuxLaunchSelection(tmuxEnabled: enableTmux, mode: effectiveTmuxAutoMode, herdrEnabled: enableHerdr) }, + get: { TmuxLaunchSelection(tmuxEnabled: enableTmux, mode: effectiveTmuxAutoMode, + herdrEnabled: enableHerdr, zmxEnabled: enableZmx) }, set: { sel in enableTmux = sel.tmuxEnabled enableHerdr = sel.herdrEnabled + enableZmx = sel.zmxEnabled if sel.tmuxEnabled { tmuxAutoMode = sel.mode } } ) @@ -1351,6 +1354,7 @@ struct ProfileEditorSheet: View { Text("tmux -CC (control)").tag(TmuxLaunchSelection.control) } Text("herdr").tag(TmuxLaunchSelection.herdr) + Text("zmx").tag(TmuxLaunchSelection.zmx) } .pickerStyle(.menu) .themedRow() @@ -1386,6 +1390,11 @@ struct ProfileEditorSheet: View { .font(.caption) .foregroundColor(.secondary) .themedRow() + } else if enableZmx { + Text("Attach to or create zmx session \"\(multiplexerCaptionSessionName)\" on connect") + .font(.caption) + .foregroundColor(.secondary) + .themedRow() } NavigationLink { @@ -1858,6 +1867,7 @@ struct ProfileEditorSheet: View { enableTmux = config.tmuxAutoEnable tmuxAutoMode = config.tmuxAutoMode enableHerdr = config.herdrAutoEnable + enableZmx = config.zmxAutoEnable // Load launch command launchCommand = config.launchCommand ?? "" @@ -1976,6 +1986,7 @@ struct ProfileEditorSheet: View { enableTmux = entry.tmuxAutoEnable ?? false tmuxAutoMode = entry.tmuxAutoMode ?? .regular enableHerdr = entry.herdrAutoEnable ?? false + enableZmx = entry.zmxAutoEnable ?? false // Launch command from history launchCommand = entry.launchCommand ?? "" @@ -2140,6 +2151,7 @@ struct ProfileEditorSheet: View { var finalConfig = sshConfig finalConfig.authMethod = sshAuthMethod finalConfig.herdrAutoEnable = enableHerdr + finalConfig.zmxAutoEnable = enableZmx // TERM override. Empty (or malformed) means inherit the global default // rather than pinning a value that would silently fall back anyway. @@ -2435,6 +2447,8 @@ private struct ProfileMultiplexerSessionEditor: View { @AppStorage("tmuxCustomCommand") private var tmuxCustomCommandSetting = "" @AppStorage("herdrSessionName") private var herdrSessionNameSetting = "" @AppStorage("herdrCustomCommand") private var herdrCustomCommandSetting = "" + @AppStorage("zmxSessionName") private var zmxSessionNameSetting = "" + @AppStorage("zmxCustomCommand") private var zmxCustomCommandSetting = "" @State private var isCustom: Bool @@ -2447,6 +2461,7 @@ private struct ProfileMultiplexerSessionEditor: View { } private var isHerdr: Bool { selection == .herdr } + private var isZmx: Bool { selection == .zmx } private var trimmed: String { sessionName.trimmingCharacters(in: .whitespacesAndNewlines) @@ -2459,20 +2474,42 @@ private struct ProfileMultiplexerSessionEditor: View { let name = herdrSessionNameSetting.trimmingCharacters(in: .whitespacesAndNewlines) return SSHConfig.isEmbeddableHerdrSessionName(name) ? name : "default" } + if isZmx { + let name = zmxSessionNameSetting.trimmingCharacters(in: .whitespacesAndNewlines) + return SSHConfig.isEmbeddableZmxSessionName(name) ? name : SSHConfig.zmxDefaultSessionName + } let name = tmuxSessionNameSetting.trimmingCharacters(in: .whitespacesAndNewlines) return name.isEmpty ? "main" : name } + /// The Settings command override for whichever multiplexer the picker + /// selected. Reading the wrong one here would disable the field for a + /// command the connection will never run. + private var customCommandSetting: String { + if isHerdr { return herdrCustomCommandSetting } + if isZmx { return zmxCustomCommandSetting } + return tmuxCustomCommandSetting + } + /// A full-command override in Settings replaces the session name outright, /// so pinning one here would do nothing. private var hasCustomCommand: Bool { - let command = isHerdr ? herdrCustomCommandSetting : tmuxCustomCommandSetting - return !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + !customCommandSetting.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } private var customWarning: String? { - guard isCustom, !trimmed.isEmpty, - !SSHConfig.isEmbeddableMultiplexerSessionName(trimmed) else { return nil } + guard isCustom, !trimmed.isEmpty else { return nil } + if isZmx { + guard !SSHConfig.isEmbeddableZmxSessionName(trimmed) else { return nil } + // A leading `-` passes the shared charset rule but `zmx attach` + // reads it as a flag, so name the real problem rather than sending + // the user hunting for an illegal character that isn't there. + if trimmed.hasPrefix("-") { + return String(localized: "zmx reads a name starting with '-' as an option. This name falls back to the global default.") + } + return String(localized: "Use letters, digits, and . _ - only, up to 64 characters. Other names fall back to the global default.") + } + guard !SSHConfig.isEmbeddableMultiplexerSessionName(trimmed) else { return nil } return String(localized: "Use letters, digits, and . _ - only, up to 64 characters. Other names fall back to the global default.") } @@ -2516,6 +2553,8 @@ private struct ProfileMultiplexerSessionEditor: View { Text("Ignored when a custom auto-start command is set in Settings.") } else if isHerdr { Text("The herdr session this profile attaches to on connect. Leave on the global default to use the session name from Settings. Names may use letters, numbers, '.', '_' and '-' (\".\" and \"..\" alone are reserved).") + } else if isZmx { + Text("The zmx session this profile attaches to or creates on connect. Leave on the global default to use the session name from Settings. Names may use letters, numbers, '.', '_' and '-', and may not start with '-'.") } else { Text("The tmux session this profile attaches to or creates on connect. Leave on the global default to use the session name from Settings. A pinned name also overrides the session you were last attached to on this host.") } diff --git a/rootshell/Features/SSH/Config/HistoryExtensionPayload.swift b/rootshell/Features/SSH/Config/HistoryExtensionPayload.swift index f3df880c..97beeeef 100644 --- a/rootshell/Features/SSH/Config/HistoryExtensionPayload.swift +++ b/rootshell/Features/SSH/Config/HistoryExtensionPayload.swift @@ -34,12 +34,15 @@ import Foundation /// from the record being replaced. struct HistoryExtensionPayload: Codable, Hashable, Sendable { /// Envelope schema version. Bump on every added member. - static let currentVersion = 2 + static let currentVersion = 3 /// Envelope version that introduced `multiplexerSessionName`. Writers below /// this predate the field, so their nil is a gap, not a clear. static let multiplexerSessionNameVersion = 2 + /// Envelope version that introduced `zmxAutoEnable`. Same rule. + static let zmxAutoEnableVersion = 3 + var version: Int /// Per-connection `TERM` override. nil means the connection inherits the @@ -50,18 +53,29 @@ struct HistoryExtensionPayload: Codable, Hashable, Sendable { /// global default for whichever multiplexer auto-start selects. var multiplexerSessionName: String? + /// Whether zmx auto-start is on for this connection. + /// + /// Deliberately here rather than as a top-level `CKRecord` field, unlike the + /// `herdrAutoEnable` line sitting right beside it in `CloudKitSyncable`. A + /// new top-level field is not free: it needs a production CloudKit schema + /// deploy, which is exactly the cost this envelope was introduced to stop + /// paying. + var zmxAutoEnable: Bool? + init( version: Int = HistoryExtensionPayload.currentVersion, terminalType: String? = nil, - multiplexerSessionName: String? = nil + multiplexerSessionName: String? = nil, + zmxAutoEnable: Bool? = nil ) { self.version = version self.terminalType = terminalType self.multiplexerSessionName = multiplexerSessionName + self.zmxAutoEnable = zmxAutoEnable } private enum CodingKeys: String, CodingKey { - case version, terminalType, multiplexerSessionName + case version, terminalType, multiplexerSessionName, zmxAutoEnable } init(from decoder: Decoder) throws { @@ -73,6 +87,7 @@ struct HistoryExtensionPayload: Codable, Hashable, Sendable { // Field-level tolerance: a bad member must not sink the envelope. terminalType = (try? container.decodeIfPresent(String.self, forKey: .terminalType)) ?? nil multiplexerSessionName = (try? container.decodeIfPresent(String.self, forKey: .multiplexerSessionName)) ?? nil + zmxAutoEnable = (try? container.decodeIfPresent(Bool.self, forKey: .zmxAutoEnable)) ?? nil } func encode(to encoder: Encoder) throws { @@ -80,5 +95,6 @@ struct HistoryExtensionPayload: Codable, Hashable, Sendable { try container.encode(version, forKey: .version) try container.encodeIfPresent(terminalType, forKey: .terminalType) try container.encodeIfPresent(multiplexerSessionName, forKey: .multiplexerSessionName) + try container.encodeIfPresent(zmxAutoEnable, forKey: .zmxAutoEnable) } } diff --git a/rootshell/Features/SSH/Config/MultiplexerSessionName.swift b/rootshell/Features/SSH/Config/MultiplexerSessionName.swift new file mode 100644 index 00000000..f3397816 --- /dev/null +++ b/rootshell/Features/SSH/Config/MultiplexerSessionName.swift @@ -0,0 +1,54 @@ +// +// MultiplexerSessionName.swift +// rootshell +// +// Which session names are safe to embed in a multiplexer's auto-start line. +// +// Split out of `SSHConfig` so the rules are reachable without the rest of the +// connection model: they are pure string predicates, and the profile editor, +// the exec-line builder and the settings captions all have to agree on them. +// Where they disagree, the editor accepts a name the connection then silently +// replaces with the default. +// + +import Foundation + +nonisolated enum MultiplexerSessionName { + /// herdr session names are ASCII alphanumerics plus `.` `_` `-`, at most + /// 64 bytes, and never the reserved `.`/`..` (herdr's validate_name + /// rejects those). Anything else can't be embedded in the single-quoted + /// exec line — or would make herdr exit instead of attaching — and + /// degrades to the default session. + static func isEmbeddableHerdr(_ name: String) -> Bool { + !name.isEmpty && name != "." && name != ".." && name.utf8.count <= 64 && name.allSatisfy { + $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "." || $0 == "_" || $0 == "-") + } + } + + /// Strictest of the multiplexers' rules, so one stored value stays legal + /// for whichever the picker selects: herdr's rule is tmux's charset plus a + /// 64-byte cap and the reserved `.`/`..` names, and it is a safe subset of + /// what zmx accepts. + /// + /// **This gates CONFIGURED names only.** It must never be applied to a name + /// coming back from `zmx list`: zmx rejects only `/`, NUL, `.` and `..`, so + /// spaces, `*`, `?`, quotes and tabs are all legal in a real session name, + /// and testing a discovered name against this rule would reject sessions the + /// user can legitimately attach to. + static func isEmbeddable(_ name: String) -> Bool { + isEmbeddableHerdr(name) + } + + /// The shared rule plus zmx's own restriction: no leading `-`. + /// + /// `zmx attach` takes trailing arguments, so a configured name of `-d` or + /// `--labels` is parsed as an option rather than a session name. The exec + /// line and the profile editor both have to agree about this, or the editor + /// accepts a name that silently degrades to the default on connect. + /// + /// **Configured names only**, with the same caveat as `isEmbeddable`: a name + /// from `zmx list` is attached verbatim and must never be tested here. + static func isEmbeddableZmx(_ name: String) -> Bool { + isEmbeddable(name) && !name.hasPrefix("-") + } +} diff --git a/rootshell/Features/SSH/Config/SSHCommandParser.swift b/rootshell/Features/SSH/Config/SSHCommandParser.swift index 75c60a26..c310d7f5 100644 --- a/rootshell/Features/SSH/Config/SSHCommandParser.swift +++ b/rootshell/Features/SSH/Config/SSHCommandParser.swift @@ -41,6 +41,7 @@ struct SSHCommandParser { var tmuxAutoEnable: Bool = false var tmuxAutoMode: TmuxAutoMode = .regular var herdrAutoEnable: Bool = false + var zmxAutoEnable: Bool = false var remoteCommand: String? var remoteCommandPolicy: SSHConfig.RemoteCommandPolicy = .verbatim /// Per-profile multiplexer session name, carried through the password @@ -62,6 +63,7 @@ struct SSHCommandParser { tmuxAutoMode: tmuxAutoMode ) config.herdrAutoEnable = herdrAutoEnable + config.zmxAutoEnable = zmxAutoEnable config.multiplexerSessionName = multiplexerSessionName config.remoteCommand = remoteCommand config.remoteCommandPolicy = remoteCommandPolicy @@ -104,6 +106,7 @@ struct SSHCommandParser { var remoteForwards: [PortForwardConfig.PortForward] = [] var tmuxAutoEnable = false var herdrAutoEnable = false + var zmxAutoEnable = false var remoteCommand: String? var remoteCommandPolicy: SSHConfig.RemoteCommandPolicy = .verbatim @@ -201,6 +204,10 @@ struct SSHCommandParser { // Enable herdr auto-attach herdrAutoEnable = true + case "--zmx": + // Enable zmx auto-attach + zmxAutoEnable = true + case Self.prependPATHFlag: // Rootshell-specific opt-in that preserves the old PATH wrapper // for shell-launched SSH exec requests. @@ -322,6 +329,7 @@ struct SSHCommandParser { tmuxAutoEnable: tmuxAutoEnable ) config.herdrAutoEnable = herdrAutoEnable + config.zmxAutoEnable = zmxAutoEnable config.remoteCommand = remoteCommand config.remoteCommandPolicy = remoteCommandPolicy return .success(config) @@ -354,6 +362,7 @@ struct SSHCommandParser { tmuxAutoEnable: tmuxAutoEnable ) config.herdrAutoEnable = herdrAutoEnable + config.zmxAutoEnable = zmxAutoEnable config.remoteCommand = remoteCommand config.remoteCommandPolicy = remoteCommandPolicy return .success(config) @@ -374,6 +383,7 @@ struct SSHCommandParser { tmuxAutoEnable: tmuxAutoEnable ) config.herdrAutoEnable = herdrAutoEnable + config.zmxAutoEnable = zmxAutoEnable config.remoteCommand = remoteCommand config.remoteCommandPolicy = remoteCommandPolicy return .success(config) @@ -399,6 +409,7 @@ struct SSHCommandParser { tmuxAutoEnable: tmuxAutoEnable ) config.herdrAutoEnable = herdrAutoEnable + config.zmxAutoEnable = zmxAutoEnable config.remoteCommand = remoteCommand config.remoteCommandPolicy = remoteCommandPolicy return .success(config) @@ -416,6 +427,7 @@ struct SSHCommandParser { cachedIP: cachedIP, tmuxAutoEnable: tmuxAutoEnable, herdrAutoEnable: herdrAutoEnable, + zmxAutoEnable: zmxAutoEnable, remoteCommand: remoteCommand, remoteCommandPolicy: remoteCommandPolicy ) diff --git a/rootshell/Features/SSH/Config/SSHConfig.swift b/rootshell/Features/SSH/Config/SSHConfig.swift index 370a44b2..c39c10c3 100644 --- a/rootshell/Features/SSH/Config/SSHConfig.swift +++ b/rootshell/Features/SSH/Config/SSHConfig.swift @@ -26,20 +26,29 @@ extension TmuxAutoMode { } /// UI-facing selection for the "Auto-start multiplexer" menu, bridging the -/// stored `(tmuxAutoEnable, tmuxAutoMode, herdrAutoEnable)` fields to a single -/// Picker selection. tmux and herdr auto-start are mutually exclusive: only -/// one exec command can win at connect. +/// stored `(tmuxAutoEnable, tmuxAutoMode, herdrAutoEnable, zmxAutoEnable)` +/// fields to a single Picker selection. tmux, herdr and zmx auto-start are +/// mutually exclusive: only one exec command can win at connect. +/// +/// The initialiser now takes three booleans encoding five valid cases, which is +/// two more representable states than there are meanings. Renaming this to +/// `MultiplexerLaunchSelection` with an enum-based init would be the right shape, +/// but it touches roughly 14 call sites; offered as a follow-up rather than done +/// inside a feature change. enum TmuxLaunchSelection: Hashable, CaseIterable { case off case regular case control case herdr + case zmx - init(tmuxEnabled: Bool, mode: TmuxAutoMode, herdrEnabled: Bool) { + init(tmuxEnabled: Bool, mode: TmuxAutoMode, herdrEnabled: Bool, zmxEnabled: Bool = false) { if tmuxEnabled { self = (mode == .control) ? .control : .regular } else if herdrEnabled { self = .herdr + } else if zmxEnabled { + self = .zmx } else { self = .off } @@ -51,6 +60,9 @@ enum TmuxLaunchSelection: Hashable, CaseIterable { /// Whether herdr auto-start is on. var herdrEnabled: Bool { self == .herdr } + /// Whether zmx auto-start is on. + var zmxEnabled: Bool { self == .zmx } + /// The persisted tmux launch mode (regular when tmux is off, which is /// irrelevant then). var mode: TmuxAutoMode { self == .control ? .control : .regular } @@ -147,6 +159,7 @@ struct SSHConfig: Codable, Hashable { /// connect. Mutually exclusive with `tmuxAutoEnable` in the UI; when both /// are somehow set, tmux wins. var herdrAutoEnable: Bool = false + var zmxAutoEnable: Bool = false /// Session name for whichever multiplexer the auto-start picker selects. /// nil or empty falls back to the global default, which is how every @@ -547,7 +560,7 @@ struct SSHConfig: Codable, Hashable { private enum CodingKeys: String, CodingKey { case host, port, username, authMethod, cachedIP, jumpHost case hssShorthand, cloudInstanceLabel, agentConfig, gpgAgentConfig, portForwardConfig - case tmuxAutoEnable, tmuxAutoMode, herdrAutoEnable, launchCommand, launchCommandMode, fallbackKeyIDs, keyResolutionHints + case tmuxAutoEnable, tmuxAutoMode, herdrAutoEnable, zmxAutoEnable, launchCommand, launchCommandMode, fallbackKeyIDs, keyResolutionHints case terminalType, multiplexerSessionName } @@ -568,6 +581,7 @@ struct SSHConfig: Codable, Hashable { tmuxAutoEnable = try container.decodeIfPresent(Bool.self, forKey: .tmuxAutoEnable) ?? false tmuxAutoMode = try container.decodeIfPresent(TmuxAutoMode.self, forKey: .tmuxAutoMode) ?? .regular herdrAutoEnable = try container.decodeIfPresent(Bool.self, forKey: .herdrAutoEnable) ?? false + zmxAutoEnable = try container.decodeIfPresent(Bool.self, forKey: .zmxAutoEnable) ?? false launchCommand = try container.decodeIfPresent(String.self, forKey: .launchCommand) launchCommandMode = try container.decodeIfPresent(LaunchCommandMode.self, forKey: .launchCommandMode) ?? .afterConnect fallbackKeyIDs = try container.decodeIfPresent([UUID].self, forKey: .fallbackKeyIDs) @@ -707,15 +721,9 @@ struct SSHConfig: Codable, Hashable { .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } - /// herdr session names are ASCII alphanumerics plus `.` `_` `-`, at most - /// 64 bytes, and never the reserved `.`/`..` (herdr's validate_name - /// rejects those). Anything else can't be embedded in the single-quoted - /// exec line — or would make herdr exit instead of attaching — and - /// degrades to the default session. + /// See `MultiplexerSessionName.isEmbeddableHerdr`. static func isEmbeddableHerdrSessionName(_ name: String) -> Bool { - !name.isEmpty && name != "." && name != ".." && name.utf8.count <= 64 && name.allSatisfy { - $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "." || $0 == "_" || $0 == "-") - } + MultiplexerSessionName.isEmbeddableHerdr(name) } /// The profile's session-name override, trimmed, or nil when unset. Which @@ -727,11 +735,14 @@ struct SSHConfig: Codable, Hashable { return name } - /// Strictest of the two multiplexers' rules, so one stored value stays - /// legal for whichever the picker selects: herdr's rule is tmux's charset - /// plus a 64-byte cap and the reserved `.`/`..` names. + /// See `MultiplexerSessionName.isEmbeddable`. static func isEmbeddableMultiplexerSessionName(_ name: String) -> Bool { - isEmbeddableHerdrSessionName(name) + MultiplexerSessionName.isEmbeddable(name) + } + + /// See `MultiplexerSessionName.isEmbeddableZmx`. + static func isEmbeddableZmxSessionName(_ name: String) -> Bool { + MultiplexerSessionName.isEmbeddableZmx(name) } /// Builds the `sh -c '...'` line that attaches to (or creates) a herdr @@ -783,6 +794,83 @@ struct SSHConfig: Codable, Hashable { return Self.herdrExecCommandLine(sessionName: herdrRawSessionNameForConnection) } + // MARK: - zmx auto-start + + /// zmx has no default-session concept: `zmx attach` with no name is an + /// error, so unlike herdr the fallback has to be a real name. + static let zmxDefaultSessionName = "main" + + /// Globally-configured zmx session name, falling back to `main`. + static var zmxGlobalSessionName: String { + let raw = UserDefaults.standard.string(forKey: "zmxSessionName")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return raw.isEmpty ? zmxDefaultSessionName : raw + } + + /// Builds the `sh -c '...'` line that attaches to (or creates) a zmx + /// session, falling back to `$SHELL` when zmx is missing. `zmx attach` is + /// attach-or-create, so no `-A` analogue is needed. Unlike herdr there is no + /// unnamed default session, so a name is always passed. + static func zmxExecCommandLine(sessionName: String) -> String { + let name = isEmbeddableZmxSessionName(sessionName) ? sessionName : zmxDefaultSessionName + // `ZMX_SESSION_PREFIX=` for the same reason as the picker's attach and + // the discovery command: `zmx attach` prepends the variable to its name + // argument while `zmx list` does not, so without this the session + // auto-start creates is not the one discovery later reports. Scoping the + // assignment to the `exec` rather than making it a standalone statement + // keeps the `|| exec $SHELL` fallback on the user's own environment; it + // deliberately avoids `env(1)`, whose absence would take the fallback + // down with it. + return "sh -c '\(remoteExecPathPrefix)command -v zmx >/dev/null" + + " && ZMX_SESSION_PREFIX= exec zmx attach \(name)" + + " || exec $SHELL'" + } + + /// Shared zmx exec command used by all session types. + /// Reads "zmxCustomCommand" and "zmxSessionName" from UserDefaults. + static var zmxExecCommand: String { + if let custom = UserDefaults.standard.string(forKey: "zmxCustomCommand"), + !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return custom + } + return zmxExecCommandLine(sessionName: zmxGlobalSessionName) + } + + /// The zmx session this connection's auto-start will land in, or nil when + /// that is not knowable. + /// + /// Nil when a custom command is configured: `zmxCustomCommand` replaces the + /// entire command line, so the session name would have to be recovered by + /// parsing arbitrary shell. `tmuxSessionNameForConnection` and + /// `herdrSessionNameForConnection` return a name regardless in that case, + /// which is merely loose for them — a wrong name makes `list-windows -t + /// ` fail and the exposé backs off. For zmx it is not loose: the + /// exposé's focus sends `.Switch` on the socket of whatever name it is + /// given, so a guessed name moves a session this pane is not attached to. + /// Answering nil and declining to bind is the honest outcome. + /// + /// Mirrors the derivation in `zmxExecCommandForConnection` exactly, + /// including the `isEmbeddableZmxSessionName` normalisation applied inside + /// `zmxExecCommandLine`, so the name recorded is the name attached to. + var zmxSessionNameForConnection: String? { + if let custom = UserDefaults.standard.string(forKey: "zmxCustomCommand"), + !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return nil + } + let raw = multiplexerSessionNameOverride ?? Self.zmxGlobalSessionName + return Self.isEmbeddableZmxSessionName(raw) ? raw : Self.zmxDefaultSessionName + } + + /// Per-connection variant of `zmxExecCommand`. A custom "zmxCustomCommand" + /// still wins. + var zmxExecCommandForConnection: String { + if let custom = UserDefaults.standard.string(forKey: "zmxCustomCommand"), + !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return custom + } + return Self.zmxExecCommandLine(sessionName: multiplexerSessionNameOverride ?? Self.zmxGlobalSessionName) + } + /// Session name the given picker selection would attach to, for the editor /// captions. Deliberately ignores TmuxGatewaySessionStore: the editor may /// describe a profile with no live connection, and the captions already @@ -810,6 +898,13 @@ struct SSHConfig: Codable, Hashable { } let raw = pinned ?? herdrGlobalSessionName return isEmbeddableHerdrSessionName(raw) ? raw : "default" + case .zmx: + if let custom = UserDefaults.standard.string(forKey: "zmxCustomCommand"), + !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "custom" + } + let raw = pinned ?? zmxGlobalSessionName + return isEmbeddableZmxSessionName(raw) ? raw : zmxDefaultSessionName } } @@ -837,7 +932,8 @@ struct SSHConfig: Codable, Hashable { /// Returns the exec command to use, if any. /// Remote command takes precedence over profile launch command and - /// multiplexer auto-start; tmux wins over herdr if both are set. + /// multiplexer auto-start; precedence among multiplexers is tmux, then + /// herdr, then zmx. var effectiveExecCommand: String? { if let remoteCommand, !remoteCommand.isEmpty { return Self.command(remoteCommand, applying: remoteCommandPolicy) @@ -851,6 +947,9 @@ struct SSHConfig: Codable, Hashable { if herdrAutoEnable { return herdrExecCommandForConnection } + if zmxAutoEnable { + return zmxExecCommandForConnection + } return nil } @@ -875,6 +974,11 @@ struct SSHConfig: Codable, Hashable { // herdr has no control mode; the same exec line works under Mosh. return herdrExecCommandForConnection } + if zmxAutoEnable { + // zmx has no control mode either, so the identical exec line works + // under Mosh -- there is nothing like tmux `-CC` to downgrade. + return zmxExecCommandForConnection + } return "$SHELL -l" } } diff --git a/rootshell/Features/SSH/Config/SSHConnectionHistory.swift b/rootshell/Features/SSH/Config/SSHConnectionHistory.swift index b1290186..a03ec026 100644 --- a/rootshell/Features/SSH/Config/SSHConnectionHistory.swift +++ b/rootshell/Features/SSH/Config/SSHConnectionHistory.swift @@ -136,6 +136,9 @@ struct SSHConnectionHistoryEntry: Codable, Identifiable, Hashable, SyncableRecor // herdr auto-enable (optional for backward compatibility) var herdrAutoEnable: Bool? + // zmx auto-enable (optional for backward compatibility) + var zmxAutoEnable: Bool? + // Launch command to run when the session is ready/started (optional for backward compatibility) var launchCommand: String? @@ -180,7 +183,7 @@ struct SSHConnectionHistoryEntry: Codable, Identifiable, Hashable, SyncableRecor init(username: String, host: String, port: Int = 22, authType: SSHAuthType, connectionProtocol: ConnectionProtocol? = nil, jumpHost: String? = nil, jumpPort: Int? = nil, jumpUsername: String? = nil, jumpAuthType: SSHAuthType? = nil, - lastUsed: Date = Date(), cachedIP: String? = nil, hssShorthand: String? = nil, agentConfig: SSHAgentConfig? = nil, gpgAgentConfig: GPGAgentConfig? = nil, portForwardConfig: PortForwardConfig? = nil, tmuxAutoEnable: Bool? = nil, tmuxAutoMode: TmuxAutoMode? = nil, herdrAutoEnable: Bool? = nil, launchCommand: String? = nil, launchCommandMode: SSHConfig.LaunchCommandMode? = nil, + lastUsed: Date = Date(), cachedIP: String? = nil, hssShorthand: String? = nil, agentConfig: SSHAgentConfig? = nil, gpgAgentConfig: GPGAgentConfig? = nil, portForwardConfig: PortForwardConfig? = nil, tmuxAutoEnable: Bool? = nil, tmuxAutoMode: TmuxAutoMode? = nil, herdrAutoEnable: Bool? = nil, zmxAutoEnable: Bool? = nil, launchCommand: String? = nil, launchCommandMode: SSHConfig.LaunchCommandMode? = nil, terminalType: String? = nil, multiplexerSessionName: String? = nil, keyResolutionHints: [String: KeyResolutionHint]? = nil) { @@ -203,6 +206,7 @@ struct SSHConnectionHistoryEntry: Codable, Identifiable, Hashable, SyncableRecor self.tmuxAutoEnable = tmuxAutoEnable self.tmuxAutoMode = tmuxAutoMode self.herdrAutoEnable = herdrAutoEnable + self.zmxAutoEnable = zmxAutoEnable self.launchCommand = launchCommand self.launchCommandMode = launchCommandMode self.terminalType = terminalType @@ -217,7 +221,7 @@ struct SSHConnectionHistoryEntry: Codable, Identifiable, Hashable, SyncableRecor connectionProtocol: ConnectionProtocol? = nil, jumpHost: String? = nil, jumpPort: Int? = nil, jumpUsername: String? = nil, jumpAuthType: SSHAuthType? = nil, lastUsed: Date = Date(), cachedIP: String? = nil, hssShorthand: String? = nil, - agentConfig: SSHAgentConfig? = nil, gpgAgentConfig: GPGAgentConfig? = nil, portForwardConfig: PortForwardConfig? = nil, tmuxAutoEnable: Bool? = nil, tmuxAutoMode: TmuxAutoMode? = nil, herdrAutoEnable: Bool? = nil, + agentConfig: SSHAgentConfig? = nil, gpgAgentConfig: GPGAgentConfig? = nil, portForwardConfig: PortForwardConfig? = nil, tmuxAutoEnable: Bool? = nil, tmuxAutoMode: TmuxAutoMode? = nil, herdrAutoEnable: Bool? = nil, zmxAutoEnable: Bool? = nil, launchCommand: String? = nil, launchCommandMode: SSHConfig.LaunchCommandMode? = nil, terminalType: String? = nil, multiplexerSessionName: String? = nil, keyResolutionHints: [String: KeyResolutionHint]? = nil, modifiedAt: Date? = nil, isDeleted: Bool = false) { self.id = id @@ -239,6 +243,7 @@ struct SSHConnectionHistoryEntry: Codable, Identifiable, Hashable, SyncableRecor self.tmuxAutoEnable = tmuxAutoEnable self.tmuxAutoMode = tmuxAutoMode self.herdrAutoEnable = herdrAutoEnable + self.zmxAutoEnable = zmxAutoEnable self.launchCommand = launchCommand self.launchCommandMode = launchCommandMode self.terminalType = terminalType @@ -253,7 +258,7 @@ struct SSHConnectionHistoryEntry: Codable, Identifiable, Hashable, SyncableRecor private enum CodingKeys: String, CodingKey { case id, username, host, port, authType, lastUsed, cachedIP case jumpHost, jumpPort, jumpUsername, jumpAuthType - case hssShorthand, agentConfig, gpgAgentConfig, portForwardConfig, tmuxAutoEnable, tmuxAutoMode, herdrAutoEnable, launchCommand, launchCommandMode + case hssShorthand, agentConfig, gpgAgentConfig, portForwardConfig, tmuxAutoEnable, tmuxAutoMode, herdrAutoEnable, zmxAutoEnable, launchCommand, launchCommandMode case connectionProtocol, keyResolutionHints case terminalType, multiplexerSessionName case modifiedAt, isDeleted @@ -284,6 +289,7 @@ struct SSHConnectionHistoryEntry: Codable, Identifiable, Hashable, SyncableRecor tmuxAutoEnable = try container.decodeIfPresent(Bool.self, forKey: .tmuxAutoEnable) tmuxAutoMode = try container.decodeIfPresent(TmuxAutoMode.self, forKey: .tmuxAutoMode) herdrAutoEnable = try container.decodeIfPresent(Bool.self, forKey: .herdrAutoEnable) + zmxAutoEnable = try container.decodeIfPresent(Bool.self, forKey: .zmxAutoEnable) launchCommand = try container.decodeIfPresent(String.self, forKey: .launchCommand) launchCommandMode = try container.decodeIfPresent(SSHConfig.LaunchCommandMode.self, forKey: .launchCommandMode) terminalType = try container.decodeIfPresent(String.self, forKey: .terminalType) @@ -465,6 +471,7 @@ class SSHConnectionHistoryManager: ObservableObject { tmuxAutoEnable: Bool? = nil, tmuxAutoMode: TmuxAutoMode? = nil, herdrAutoEnable: Bool? = nil, + zmxAutoEnable: Bool? = nil, launchCommand: String? = nil, launchCommandMode: SSHConfig.LaunchCommandMode? = nil, terminalType: String? = nil, @@ -507,6 +514,9 @@ class SSHConnectionHistoryManager: ObservableObject { if let herdrAutoEnable = herdrAutoEnable { updated.herdrAutoEnable = herdrAutoEnable } + if let zmxAutoEnable = zmxAutoEnable { + updated.zmxAutoEnable = zmxAutoEnable + } updated.launchCommand = launchCommand updated.launchCommandMode = launchCommandMode // Always write, like launchCommand: nil means the user cleared the @@ -561,6 +571,9 @@ class SSHConnectionHistoryManager: ObservableObject { if let herdrAutoEnable = herdrAutoEnable { updated.herdrAutoEnable = herdrAutoEnable } + if let zmxAutoEnable = zmxAutoEnable { + updated.zmxAutoEnable = zmxAutoEnable + } updated.launchCommand = launchCommand updated.launchCommandMode = launchCommandMode // Always write, like launchCommand: nil means the user cleared the @@ -591,6 +604,7 @@ class SSHConnectionHistoryManager: ObservableObject { tmuxAutoEnable: tmuxAutoEnable, tmuxAutoMode: tmuxAutoMode, herdrAutoEnable: herdrAutoEnable, + zmxAutoEnable: zmxAutoEnable, launchCommand: launchCommand, launchCommandMode: launchCommandMode, terminalType: terminalType, @@ -683,6 +697,30 @@ class SSHConnectionHistoryManager: ObservableObject { continue // Local is newer, keep local } // Remote is newer - update existing record (keep local UUID) + // + // The envelope-gated fields are resolved into locals first. They + // are not just for readability: inlining both ternaries into the + // initialiser below pushes it past the type-checker's budget + // ("unable to type-check this expression in reasonable time"). + let envelopeVersion = remote.syncEnvelopeVersion ?? 0 + // Gated on the envelope version, not on its presence: a build + // that predates this field still writes a valid (older) + // envelope, and its silence is a gap rather than a clear. + // Without this, reconnecting once on an older device during a + // mixed-version rollout erases the override. + let mergedMultiplexerSessionName: String? = + envelopeVersion >= HistoryExtensionPayload.multiplexerSessionNameVersion + ? remote.multiplexerSessionName + : (remote.multiplexerSessionName ?? existing.multiplexerSessionName) + // Same rule, different introducing version. Note this is NOT the + // plain-optional fallback `herdrAutoEnable` uses below: that + // field has its own CloudKit record column, so its absence is + // unambiguous, whereas an envelope member is absent both when + // the user cleared it and when the writer predates it. + let mergedZmxAutoEnable: Bool? = + envelopeVersion >= HistoryExtensionPayload.zmxAutoEnableVersion + ? remote.zmxAutoEnable + : (remote.zmxAutoEnable ?? existing.zmxAutoEnable) let updated = SSHConnectionHistoryEntry( id: existingUUID, // Keep local UUID for consistency username: remote.username, @@ -703,6 +741,7 @@ class SSHConnectionHistoryManager: ObservableObject { tmuxAutoEnable: remote.tmuxAutoEnable ?? existing.tmuxAutoEnable, tmuxAutoMode: remote.tmuxAutoMode ?? existing.tmuxAutoMode, herdrAutoEnable: remote.herdrAutoEnable ?? existing.herdrAutoEnable, + zmxAutoEnable: mergedZmxAutoEnable, launchCommand: remote.launchCommand ?? existing.launchCommand, launchCommandMode: remote.launchCommandMode ?? existing.launchCommandMode, // Remote is strictly newer here, so when it carried an @@ -713,14 +752,7 @@ class SSHConnectionHistoryManager: ObservableObject { terminalType: remote.syncCarriedExtensions ? remote.terminalType : (remote.terminalType ?? existing.terminalType), - // Gated on the envelope version, not on its presence: a - // build that predates this field still writes a valid - // (older) envelope, and its silence is a gap rather than a - // clear. Without this, reconnecting once on an older device - // during a mixed-version rollout erases the override. - multiplexerSessionName: (remote.syncEnvelopeVersion ?? 0) >= HistoryExtensionPayload.multiplexerSessionNameVersion - ? remote.multiplexerSessionName - : (remote.multiplexerSessionName ?? existing.multiplexerSessionName), + multiplexerSessionName: mergedMultiplexerSessionName, keyResolutionHints: remote.keyResolutionHints ?? existing.keyResolutionHints, modifiedAt: remote.modifiedAt, isDeleted: remote.isDeleted diff --git a/rootshell/Features/SSH/Discovery/SessionDiscoveryRunner.swift b/rootshell/Features/SSH/Discovery/SessionDiscoveryRunner.swift index e2fdb0aa..1491bdcd 100644 --- a/rootshell/Features/SSH/Discovery/SessionDiscoveryRunner.swift +++ b/rootshell/Features/SSH/Discovery/SessionDiscoveryRunner.swift @@ -2,7 +2,7 @@ // SessionDiscoveryRunner.swift // rootshell // -// Combined orchestrator that discovers tmux, zellij, and herdr sessions via a single SSH exec channel. +// Combined orchestrator that discovers tmux, zellij, herdr, and zmx sessions via a single SSH exec channel. // import Foundation @@ -24,7 +24,7 @@ struct SessionDiscoveryResult: Sendable { // MARK: - Combined Discovery Command enum SessionDiscoveryCommand { - /// Builds a single shell command that discovers tmux, zellij, and herdr sessions. + /// Builds a single shell command that discovers tmux, zellij, herdr, and zmx sessions. /// Each section is wrapped in nonce-tagged markers so captured terminal content /// (which may contain marker strings if this source code is visible) cannot collide. /// Uses a single SSH exec channel for efficiency. @@ -32,6 +32,7 @@ enum SessionDiscoveryCommand { skipTmuxSessions: Bool, skipZellijSessions: Bool, skipHerdrSessions: Bool, + skipZmxSessions: Bool, discoverTmuxBindings: Bool, discoverZellijBindings: Bool, skipCaptures: Bool = false @@ -159,6 +160,60 @@ enum SessionDiscoveryCommand { ) } + if !skipZmxSessions { + // `zmx list` probes every session socket serially, so one wedged + // daemon costs a second of the budget this command shares with + // tmux, zellij and herdr. Three choices bound that: + // + // 1. ONE `zmx list`. The capture loop derives its names from that + // same output rather than a second `--short` call, which would + // pay for every wedged daemon twice. `grep -F` on a tab before + // `pid=` keeps only real rows; `cut -f1` yields the bare name. + // 2. NO tight `timeout` on the list. It emits nothing until every + // probe finishes, so a timeout that fires costs the full wall + // clock AND returns nothing -- indistinguishable to the user + // from zmx not being installed. The 5 s backstop only catches a + // genuine hang. + // 3. `timeout 2` on each `zmx history`, where truncation costs one + // preview rather than the whole result. + // + // Session names may contain spaces, `*`, `?` and quotes, so the loop + // reads them with `IFS= read -r` rather than `for s in $(...)`, + // which would split and glob them. herdr can use that idiom because + // its names are constrained; zmx's are not. + // + // `ZMX_SESSION_PREFIX=` on every call: `zmx list` reports real + // socket names but every other subcommand prepends the variable, so + // an exported prefix would silently point `history` at a different + // session. The same neutralisation is applied in + // `TerminalView+Session.attachToSession` and + // `SSHConfig.zmxExecCommandLine`. + var section = "_zt=\"\"; command -v timeout >/dev/null 2>&1 && _zt=\"timeout 2\";" + + " _zl=$(ZMX_SESSION_PREFIX= ${_zt:+timeout 5} zmx list 2>/dev/null);" + + " echo \"::SESSIONS::\";" + + " printf \"%s\\n\" \"$_zl\";" + if !skipCaptures { + // NOTE: the "\t" below is a REAL tab in the emitted shell, which + // is what makes it match a field boundary rather than the letter + // t. Do not "tidy" it into an escape sequence. + section += " echo \"::CAPTURES::\";" + + " printf \"%s\\n\" \"$_zl\" | grep -F \"\tpid=\"" + + " | sed \"s/^[^=]*=//\" | cut -f1 | head -n 8 |" + + " while IFS= read -r s; do" + + " printf \"::CAPTURE:%s::\\n\" \"$s\";" + + " ZMX_SESSION_PREFIX= $_zt zmx history \"$s\" --vt 2>/dev/null | tail -n 200 || true;" + + " printf \"\\n\";" + + " done;" + } + parts.append( + "echo \"::ZMX_START_\(nonce)::\";" + + " if command -v zmx >/dev/null 2>&1; then" + + " \(section)" + + " fi;" + + " echo \"::ZMX_END_\(nonce)::\"" + ) + } + let body = parts.joined(separator: " ; ") return ("sh -lc '\(SSHConfig.remoteExecPathPrefix)\(body)'", nonce) } @@ -175,6 +230,7 @@ extension SessionDiscoveryRunner { skipTmuxSessions: Bool = false, skipZellijSessions: Bool = false, skipHerdrSessions: Bool = false, + skipZmxSessions: Bool = false, discoverTmuxBindings: Bool = true, discoverZellijBindings: Bool = true ) async throws -> SessionDiscoveryResult { @@ -188,6 +244,7 @@ extension SessionDiscoveryRunner { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: discoverTmuxBindings, discoverZellijBindings: discoverZellijBindings ) @@ -211,6 +268,7 @@ extension SessionDiscoveryRunner { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: discoverTmuxBindings, discoverZellijBindings: discoverZellijBindings, nonce: nonce @@ -228,13 +286,14 @@ enum SessionDiscoveryParser { category: "SessionDiscoveryParser" ) - /// Parse combined output, extracting tmux, zellij, and herdr sections independently. + /// Parse combined output, extracting tmux, zellij, herdr, and zmx sections independently. /// Nonce-tagged markers ensure captured terminal content cannot cause false matches. static func parse( output: String, skipTmuxSessions: Bool, skipZellijSessions: Bool, skipHerdrSessions: Bool, + skipZmxSessions: Bool, discoverTmuxBindings: Bool, discoverZellijBindings: Bool, nonce: String @@ -242,6 +301,7 @@ enum SessionDiscoveryParser { var tmuxSessions: [TmuxSessionInfo] = [] var zellijSessions: [ZellijSessionInfo] = [] var herdrSessions: [HerdrSessionInfo] = [] + var zmxSessions: [ZmxSessionInfo] = [] var swipeBindings = MultiplexerSwipeBindings() // Extract and parse tmux section @@ -278,13 +338,28 @@ enum SessionDiscoveryParser { herdrSessions = HerdrDiscoveryParser.parse(output: herdrOutput) } - return merge(tmux: tmuxSessions, zellij: zellijSessions, herdr: herdrSessions, swipeBindings: swipeBindings) + // Extract and parse zmx section + if !skipZmxSessions, + let zmxStart = output.range(of: "::ZMX_START_\(nonce)::"), + let zmxEnd = output.range(of: "::ZMX_END_\(nonce)::") { + let zmxOutput = String(output[zmxStart.upperBound.. SessionDiscoveryResult { var types = Set() @@ -305,6 +380,11 @@ enum SessionDiscoveryParser { sessions.append(contentsOf: herdr.map { MultiplexerSession.from(herdr: $0) }) } + if !zmx.isEmpty { + types.insert(.zmx) + sessions.append(contentsOf: zmx.map { MultiplexerSession.from(zmx: $0) }) + } + let rawOrder = UserDefaults.standard.string(forKey: SessionDiscoverySortOrder.storageKey) let order = rawOrder.flatMap(SessionDiscoverySortOrder.init(rawValue:)) ?? .attachedFirst sessions.sort(by: order.compare) @@ -341,6 +421,7 @@ enum SessionDiscoveryRunner { skipTmuxSessions: Bool = false, skipZellijSessions: Bool = false, skipHerdrSessions: Bool = false, + skipZmxSessions: Bool = false, discoverTmuxBindings: Bool = true, discoverZellijBindings: Bool = true ) async throws -> SessionDiscoveryResult { @@ -352,6 +433,7 @@ enum SessionDiscoveryRunner { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: discoverTmuxBindings, discoverZellijBindings: discoverZellijBindings ) @@ -367,6 +449,7 @@ enum SessionDiscoveryRunner { skipTmuxSessions: Bool = false, skipZellijSessions: Bool = false, skipHerdrSessions: Bool = false, + skipZmxSessions: Bool = false, discoverTmuxBindings: Bool = true, discoverZellijBindings: Bool = true, onKeyboardInteractiveChallenge: ((KeyboardInteractiveChallenge) async -> [String]?)? = nil @@ -393,6 +476,7 @@ enum SessionDiscoveryRunner { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: discoverTmuxBindings, discoverZellijBindings: discoverZellijBindings ) @@ -415,6 +499,7 @@ enum SessionDiscoveryRunner { skipTmuxSessions: Bool, skipZellijSessions: Bool, skipHerdrSessions: Bool, + skipZmxSessions: Bool, discoverTmuxBindings: Bool, discoverZellijBindings: Bool ) async throws -> SessionDiscoveryResult { @@ -424,6 +509,7 @@ enum SessionDiscoveryRunner { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: discoverTmuxBindings, discoverZellijBindings: discoverZellijBindings, skipCaptures: false @@ -443,6 +529,7 @@ enum SessionDiscoveryRunner { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: false, discoverZellijBindings: false, skipCaptures: true @@ -463,6 +550,7 @@ enum SessionDiscoveryRunner { skipTmuxSessions: Bool, skipZellijSessions: Bool, skipHerdrSessions: Bool, + skipZmxSessions: Bool, discoverTmuxBindings: Bool, discoverZellijBindings: Bool, skipCaptures: Bool @@ -471,6 +559,7 @@ enum SessionDiscoveryRunner { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: discoverTmuxBindings, discoverZellijBindings: discoverZellijBindings, skipCaptures: skipCaptures @@ -500,6 +589,7 @@ enum SessionDiscoveryRunner { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: discoverTmuxBindings, discoverZellijBindings: discoverZellijBindings, nonce: nonce diff --git a/rootshell/Features/SSH/Discovery/ZmxDiscoveryParser.swift b/rootshell/Features/SSH/Discovery/ZmxDiscoveryParser.swift new file mode 100644 index 00000000..9631c609 --- /dev/null +++ b/rootshell/Features/SSH/Discovery/ZmxDiscoveryParser.swift @@ -0,0 +1,397 @@ +// +// ZmxDiscoveryParser.swift +// rootshell +// +// Data model and parser for zmx session detection on SSH hosts. zmx is a +// session attach/detach multiplexer: one session is exactly one PTY, with no +// windows, tabs, splits or control protocol. `zmx list` is its only +// enumeration primitive, and `zmx history --vt` provides the preview. +// + +import Foundation +import os + +// MARK: - Data Model + +struct ZmxSessionInfo: Identifiable, Equatable, Sendable { + let name: String + + /// Attached interactive clients, or nil when `clients=` was absent or + /// unparseable. zmx already excludes the probing connection + /// (`loop.zig` `handleInfo` sends `clients.items.len - 1`), so 0 genuinely + /// means nobody is attached — it is not an artefact of our own listing. + /// + /// Optional rather than `Int` because "no client count" and "zero clients" + /// are different facts, and the picker's detail text must not render the + /// second as "0 clients". + let clientCount: Int? + + let createdAt: Date? + + /// Working directory, from two different fields with two different shapes: + /// + /// - zmx HEAD emits `cwd=` as an OSC 7 URI (`file://host/percent%20encoded`) + /// that tracks the shell live. + /// - zmx v0.7.0 emits `start_dir=`, the bare absolute path the session was + /// created in, which never changes. + /// + /// Both land here already decoded. A v0.7.0 path is used verbatim: running a + /// bare path through a URI decoder yields nothing useful and would blank the + /// subtitle on every v0.7.0 host. + let cwd: String? + + /// Host component of the OSC 7 URI, when the session reported one. Always + /// nil on v0.7.0, which has no URI form at all. + let cwdHost: String? + + /// Explicit launch command, present when the session was created by + /// `zmx attach ` rather than as a bare shell. Note that + /// `zmx run` (task mode) sessions do not carry one. + let command: String? + + /// Labels from `zmx set` / `zmx attach --labels`. zmx restricts both label + /// keys and values to `[A-Za-z0-9._-]`, so these never contain `=` or tabs. + let labels: [String: String] + + var capturedContent: String? + + var id: String { name } + + var isAttached: Bool { (clientCount ?? 0) > 0 } + + /// Relative creation time, e.g. "Created 2 hr. ago", echoing the shape + /// zellij rows already use in the picker. Empty when zmx gave no timestamp. + /// + /// zellij's equivalent is scraped verbatim out of zellij's own output + /// (`[Created 2h ago]`), so there is no existing formatter to share; zmx + /// reports a unix timestamp and this formats it. + var createdAgo: String { + guard let createdAt else { return "" } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + let relative = formatter.localizedString(for: createdAt, relativeTo: Date()) + return String( + format: String(localized: "Created %@", comment: "zmx session age, e.g. 'Created 2 hr. ago'"), + relative + ) + } +} + +// MARK: - Parser + +enum ZmxDiscoveryParser { + + private nonisolated static let logger = Logger( + subsystem: "com.rootshell", + category: "ZmxDiscoveryParser" + ) + + /// The built-in fields `writeSessionLine` (`util.zig`) emits, in the + /// exact order it emits them, after the leading `name=`. + /// + /// Order is the whole point. Every key outside this list is a label, and + /// labels print *after* the built-ins — but zmx reserves only `name`, + /// `start_dir` and `cmd` (`label.zig`), so `zmx set api clients=3` and + /// `zmx set api err=nope` are both perfectly legal. A parser that assigned + /// by key lookup would let a label overwrite the real attached-client count, + /// and a rule as blunt as "skip rows containing `err=`" would make a healthy + /// session vanish from the picker with no explanation. + private nonisolated static let builtInOrder = [ + "pid", "clients", "created", "cwd", "cmd", "ended", "exit_code", + ] + + /// v0.7.0's name for the `cwd` slot. Same position, different shape. + private nonisolated static let legacyCwdKey = "start_dir" + + nonisolated static func parse(output: String) -> [ZmxSessionInfo] { + let parts = output.components(separatedBy: "::SESSIONS::") + guard parts.count >= 2 else { return [] } + + let capturesSplit = parts[1].components(separatedBy: "::CAPTURES::") + let sessionsBlock = capturesSplit[0] + let capturesBlock = capturesSplit.count >= 2 ? capturesSplit[1] : "" + + let capturesBySession = parseCaptures(block: capturesBlock) + + var sessions: [ZmxSessionInfo] = [] + // Keyed by name so a duplicate `name=` cannot produce two rows with the + // same Identifiable id, which SwiftUI's ForEach will not tolerate. zmx + // should never emit one (a session name *is* its socket filename), so + // this is purely defensive; last row wins. + var indexByName: [String: Int] = [:] + + for rawLine in sessionsBlock.split(whereSeparator: \.isNewline) { + guard let session = parseLine(String(rawLine)) else { continue } + let enriched = ZmxSessionInfo( + name: session.name, + clientCount: session.clientCount, + createdAt: session.createdAt, + cwd: session.cwd, + cwdHost: session.cwdHost, + command: session.command, + labels: session.labels, + capturedContent: capturesBySession[session.name] + ) + if let existing = indexByName[session.name] { + sessions[existing] = enriched + } else { + indexByName[session.name] = sessions.count + sessions.append(enriched) + } + } + + let count = sessions.count + logger.info("Parsed \(count) zmx sessions") + return sessions + } + + /// Parses one `zmx list` row. Returns nil for anything that is not a live + /// session: blank lines, stray shell output, and genuine error rows. + private nonisolated static func parseLine(_ rawLine: String) -> ZmxSessionInfo? { + // Strip the row prefix. `writeSessionLine` emits "→ " when the row is the + // caller's own $ZMX_SESSION and two spaces otherwise. + // + // Note there is no third, unprefixed case in practice, despite what the + // shape of the Zig suggests: `getSeshNameFromEnv` (`socket.zig`) returns + // `getenv("ZMX_SESSION") orelse ""` — an empty *string*, never null — so + // `current_session` is always non-nil and the `else => ""` branch is + // unreachable from the `list` command. Every row on every host carries a + // prefix, which makes stripping mandatory rather than defensive: skipping + // it yields an empty picker everywhere, silently. + // + // The arrow is a three-byte UTF-8 scalar followed by a space, so + // whitespace trimming alone does not remove it. + var line = rawLine + if line.hasPrefix("→ ") { + line = String(line.dropFirst(2)) + } + // Also drop a trailing CR, so CRLF-terminated output parses. + if line.hasSuffix("\r") { + line = String(line.dropLast()) + } + line = line.trimmingCharacters(in: .whitespaces) + guard !line.isEmpty else { return nil } + + // Split on tabs, then each field on its FIRST `=` only: `cmd=` values are + // copied straight from the session's argv (`loop.zig`) without going + // through label validation, so they legitimately contain `=`. + let fields = line.components(separatedBy: "\t").map(splitField) + + guard let first = fields.first, first.key == "name", !first.value.isEmpty else { + return nil + } + + // A genuine error row is `name=\terr=\tstatus=` and nothing else + // (`util.zig`), so `err` appears as the second field and there is + // no `pid`. A *label* named `err` can only ever appear after the built-ins, + // on a row that does have a `pid`. Checking the position is what keeps + // `zmx set api err=nope` from deleting a live session from the picker. + if fields.count > 1, fields[1].key == "err" { + return nil + } + + var values: [String: String] = [:] + var labels: [String: String] = [:] + var cursor = 0 + var inLabels = false + // Which spelling filled the cwd slot; the two shapes decode differently. + var cwdIsURI = false + + for field in fields.dropFirst() { + if !inLabels { + // `start_dir` (v0.7.0) occupies the same slot as `cwd` (HEAD). + let normalized = field.key == legacyCwdKey ? "cwd" : field.key + if let offset = builtInOrder[cursor...].firstIndex(of: normalized) { + values[normalized] = field.value + if normalized == "cwd" { + cwdIsURI = field.key == "cwd" + } + cursor = offset + 1 + continue + } + // First out-of-sequence key: everything from here on is a label. + inLabels = true + } + if !field.key.isEmpty { + labels[field.key] = field.value + } + } + + let (cwd, cwdHost) = decodeWorkingDirectory(values["cwd"], isURI: cwdIsURI) + + // Non-numeric pid/clients/created degrade to nil rather than dropping the + // row: a session we cannot fully describe is still a session the user can + // attach to. + return ZmxSessionInfo( + name: first.value, + clientCount: values["clients"].flatMap(Int.init), + createdAt: values["created"].flatMap(Double.init).map(Date.init(timeIntervalSince1970:)), + cwd: cwd, + cwdHost: cwdHost, + command: values["cmd"].flatMap { $0.isEmpty ? nil : $0 }, + labels: labels, + capturedContent: nil + ) + } + + /// Splits `key=value` on the first `=` only. A field with no `=` becomes a + /// key with an empty value, which the callers treat as uninteresting. + private nonisolated static func splitField(_ field: String) -> (key: String, value: String) { + guard let separator = field.firstIndex(of: "=") else { + return (field, "") + } + return ( + String(field[field.startIndex.. (cwd: String?, host: String?) { + guard let value, !value.isEmpty else { return (nil, nil) } + guard isURI else { return (value, nil) } + let decoded = WorkingDirectoryURI.decode(value) + return (decoded.path, decoded.host) + } + + /// Parses `::CAPTURE:::` chunks of raw ANSI content, the same chunk + /// shape the tmux, zellij and herdr parsers use. + /// + /// This is the third copy of this splitter in the codebase. Extracting a + /// shared helper would mean editing two working parsers to save a dozen + /// lines; the duplication is deliberate and offered as a follow-up instead. + private nonisolated static func parseCaptures(block: String) -> [String: String] { + guard !block.isEmpty else { return [:] } + var capturesBySession: [String: String] = [:] + for chunk in block.components(separatedBy: "::CAPTURE:") { + guard let markerEnd = chunk.range(of: "::\n") ?? chunk.range(of: "::\r\n") else { continue } + let sessionName = String(chunk[chunk.startIndex.. String { + guard let lastClear = content.range(of: "\u{1B}[2J", options: .backwards) else { + return content + } + return String(content[lastClear.upperBound...]) + } +} + +// MARK: - Preview Harness + +#if DEBUG +import SwiftUI + +/// Renders the parser's output for the repository's captured `zmx list` +/// fixtures straight into the real picker. +/// +/// There is no unit-test target for app code, so this is the parser's coverage: +/// it needs no target changes, survives refactors, and gives every fixture below +/// a visual assertion in the Xcode canvas. +/// +/// The fixture text is deliberately inline rather than read from disk — a +/// preview has no useful working directory — and every `\t` below is a real tab +/// once Swift parses the literal, which is exactly what the format requires. +private enum ZmxPreviewFixtures { + + /// zmx HEAD. Note every row carries the two-space prefix: `zmx list` always + /// emits one, so a parser that skipped stripping would show an empty picker. + static let head = """ + ::SESSIONS:: + name=api\tpid=4821\tclients=0\tcreated=\(ts(-7_200))\tcwd=file://box/home/dave/src/api\tproject=api + name=agent-1\tpid=4990\tclients=2\tcreated=\(ts(-3_600))\tcwd=file://box/home/dave/src/web%20app\tagent=claude + name=my project\tpid=5001\tclients=1\tcreated=\(ts(-600))\tcwd=file://box/home/dave + name=task\tpid=5002\tclients=0\tcreated=\(ts(-900))\tcwd=file://box/tmp\tended=\(ts(-30))\texit_code=1 + name=cmdtest\tpid=5003\tclients=0\tcreated=\(ts(-120))\tcwd=file://box/home/dave\tcmd=sh -c 'echo a=b; sleep infinity' + name=shadow\tpid=5004\tclients=0\tcreated=\(ts(-60))\tcwd=file://box/home/dave\tclients=99\terr=nope + name=stale\terr=ConnectionRefused\tstatus=cleaning up + name=frozen\terr=Timeout\tstatus=unreachable + ::CAPTURES:: + ::CAPTURE:api:: + dave@box:~/src/api$ ./run --watch + listening on :8080 + ::CAPTURE:agent-1:: + \u{1B}[1m\u{1B}[38;5;4m● Working\u{1B}[0m on the parser + """ + + /// zmx v0.7.0: `start_dir=` bare paths, which must not go through the URI + /// decoder. The `web app` path proves it — a literal space, not `%20`. + static let legacy = """ + ::SESSIONS:: + name=api\tpid=4821\tclients=0\tcreated=\(ts(-7_200))\tstart_dir=/home/dave/src/api\tproject=api + name=agent-1\tpid=4990\tclients=1\tcreated=\(ts(-3_600))\tstart_dir=/home/dave/src/web app\tagent=claude + """ + + private static func ts(_ offset: TimeInterval) -> String { + String(Int(Date().addingTimeInterval(offset).timeIntervalSince1970)) + } +} + +private struct ZmxParserPreview: View { + let title: String + let fixture: String + @State private var attachMode: TmuxAutoMode = .regular + @State private var selected = 0 + + var body: some View { + let sessions = ZmxDiscoveryParser.parse(output: fixture) + .map { MultiplexerSession.from(zmx: $0) } + VStack(spacing: 0) { + // `verbatim:` keeps this debug-only preview label out of the + // string catalog; it is developer chrome, not shipped UI. + Text(verbatim: "\(title) — \(sessions.count) sessions") + .font(.caption.monospaced()) + .padding(6) + SessionPickerOverlay( + sessions: sessions, + sessionTypes: [.zmx], + selectedIndex: min(selected, max(sessions.count - 1, 0)), + hasUserTyped: false, + tmuxAttachMode: $attachMode, + allowsTmuxControlAttach: false, + onSelect: { _ in }, + onChangeSelection: { selected = $0 }, + onDismiss: {} + ) + } + } +} + +/// Expect 6 rows: both error rows dropped, `shadow` kept with its real +/// `clients=0` (the `clients=99` label must not win), `web app` percent-decoded, +/// and `cmd=` intact past its embedded `=`. +#Preview("zmx HEAD") { + ZmxParserPreview(title: "HEAD (cwd= URIs)", fixture: ZmxPreviewFixtures.head) +} + +/// Expect 2 rows with usable paths and no URI-decoder damage. +#Preview("zmx v0.7.0") { + ZmxParserPreview(title: "v0.7.0 (start_dir= paths)", fixture: ZmxPreviewFixtures.legacy) +} +#endif diff --git a/rootshell/Features/SSH/Session/MultiplexerSession.swift b/rootshell/Features/SSH/Session/MultiplexerSession.swift index f83cf403..63f292f9 100644 --- a/rootshell/Features/SSH/Session/MultiplexerSession.swift +++ b/rootshell/Features/SSH/Session/MultiplexerSession.swift @@ -2,7 +2,7 @@ // MultiplexerSession.swift // rootshell // -// Unified model for terminal multiplexer sessions (tmux, zellij, herdr). +// Unified model for terminal multiplexer sessions (tmux, zellij, herdr, zmx). // import Foundation @@ -13,6 +13,52 @@ enum MultiplexerType: String, Sendable, Equatable, Hashable { case tmux case zellij case herdr + case zmx + + /// Whether this multiplexer takes the alternate screen for the whole + /// attach and masks the inner program from the surface. + /// + /// This is the line between the two binding slots on `TerminalView`. + /// `rawMultiplexer` is for the multiplexers this is true of: they own the + /// screen, so the surface shows their composite rather than any one + /// program, and agent detection has to stand down on the pane. + /// `passthroughMultiplexer` is for the ones it is false of: they hand the + /// PTY through untouched, so the surface shows exactly what is running and + /// agent detection must keep working. Both are identity for the exposé; + /// only the first is a suppression signal. (id=agent-attention-raw-mux) + /// + /// zmx is the passthrough: no windows, no splits, no status bar, and + /// `serializeTerminalState` deliberately does not leak the alternate + /// screen. Whatever alt-screen ownership a zmx pane shows belongs to the + /// program the user is running inside it, and lapses when that program + /// exits — which is why a zmx pane must never reach the alt-screen-based + /// release path in `AgentAttentionCenter.updateRawMultiplexerDetach`. + var ownsAlternateScreen: Bool { + switch self { + case .tmux, .zellij, .herdr: return true + case .zmx: return false + } + } + + /// SF Symbol representing this multiplexer. + /// + /// Owned here rather than repeated per view. The settings screen and the + /// session picker each had their own hardcoded ladder, and the two disagreed: + /// settings drew tmux as `rectangle.split.2x1` and zellij as + /// `rectangle.split.3x1`, while the picker drew them the other way round. + /// One source of truth fixes that mismatch and makes the next multiplexer a + /// compiler-checked addition rather than two more ladders to remember. + /// + /// zmx gets an undivided `rectangle`: it has no windows, tabs or splits, so a + /// single pane is the honest picture of it. + var iconName: String { + switch self { + case .tmux: return "rectangle.split.2x1" + case .zellij: return "rectangle.split.3x1" + case .herdr: return "square.grid.2x2" + case .zmx: return "rectangle" + } + } } // MARK: - Unified Session Model @@ -25,6 +71,14 @@ struct MultiplexerSession: Identifiable, Equatable, Sendable { let detail: String /// Secondary info: active command+path for tmux, nil for zellij let subtitle: String? + /// Absolute working directory the session reports, when it reports one. + /// + /// Only zmx supplies this today. It is deliberately the RAW path, not the + /// `~`-collapsed `subtitle` form: this feeds `terminal.pwd` on attach so the + /// agent card can name a project (see `attachToSession`), and that consumer + /// needs something `AgentProjectPath` can normalise and a remote `git -C` + /// can be pointed at. + let workingDirectory: String? /// ANSI-captured terminal content for preview rendering var capturedContent: String? /// Whether this is an exited zellij session that can be resurrected @@ -48,17 +102,29 @@ extension MultiplexerSession { isAttached: session.isAttached, detail: detail, subtitle: subtitle.isEmpty ? nil : subtitle, + // tmux panes are bound as a raw multiplexer, and `noteProject` + // refuses outright inside one, so a directory here would be + // carried and never used. + workingDirectory: nil, capturedContent: session.capturedContent, isExited: false ) } + /// Collapses a home directory to `~`, e.g. `/home/dave/src/api` -> + /// `~/src/api`. The two roots are matched literally because these paths come + /// off a remote host: there is nothing here to stat and no `$HOME` to read. + /// + /// Written in terms of the matched root's own length rather than a hardcoded + /// character count. The previous form dropped `5 + user.count` for `/home/` + /// and `6 + user.count` for `/Users/`, both one short of the root, so every + /// home path rendered with the last letter of the username still glued to + /// the tilde -- `/home/dave` as `~e`, not `~`. private static func shortenPath(_ path: String) -> String { - if path.hasPrefix("/home/") { - return "~" + String(path.dropFirst(5 + path.dropFirst(6).prefix(while: { $0 != "/" }).count)) - } - if path.hasPrefix("/Users/") { - return "~" + String(path.dropFirst(6 + path.dropFirst(7).prefix(while: { $0 != "/" }).count)) + for root in ["/home/", "/Users/"] where path.hasPrefix(root) { + let afterRoot = path.dropFirst(root.count) + let username = afterRoot.prefix(while: { $0 != "/" }) + return "~" + afterRoot.dropFirst(username.count) } return path } @@ -74,12 +140,60 @@ extension MultiplexerSession { isAttached: session.isAttached, detail: session.createdAgo, subtitle: nil, + workingDirectory: nil, capturedContent: session.capturedContent, isExited: session.isExited ) } } +// MARK: - Factory: from ZmxSessionInfo + +extension MultiplexerSession { + static func from(zmx session: ZmxSessionInfo) -> MultiplexerSession { + // zmx has no windows to count, so fall back to zellij's created-ago + // shape when nobody is attached -- "detached" is already conveyed by the + // status pill, and "0 clients" would just restate it less clearly. + // One plural key rather than a hand-rolled singular/plural pair: the + // catalog ships locales with more than two plural forms (pl, uk, ar, cs, + // sl, ro), and `String(localized:)` with an interpolated count resolves + // the right one. Matches how `%lld background agents still running` is + // handled elsewhere in the app. + let clientCount = session.clientCount ?? 0 + let detail = clientCount > 0 + ? String(localized: "\(clientCount) clients", comment: "zmx attached client count") + : session.createdAgo + + // `map { shortenPath($0) }`, not `map(shortenPath)`. The project builds + // with `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` + // (`Configuration/Base.xcconfig:20`), so `shortenPath` is MainActor- + // isolated; passing it as a function VALUE crosses the isolation + // boundary and warns, while a closure literal just inherits the + // context. The tmux factory above already uses the closure form. + let subtitle = [session.labels["project"], session.cwd.map { shortenPath($0) }] + .compactMap { $0 } + .joined(separator: " · ") + + return MultiplexerSession( + type: .zmx, + name: session.name, + isAttached: session.isAttached, + detail: detail, + subtitle: subtitle.isEmpty ? nil : subtitle, + // The one multiplexer that can answer this. zmx reports `cwd=` in + // `zmx list` and rootshell is not bound as a raw multiplexer over + // it, so the directory both survives to here and can actually be + // used -- see `attachToSession`. + workingDirectory: session.cwd, + capturedContent: session.capturedContent, + // A listed zmx session is always live: error rows are dropped by the + // parser, and `ended=` describes a finished `zmx run` task, not a + // dead session -- its daemon is still up and still attachable. + isExited: false + ) + } +} + // MARK: - Factory: from HerdrSessionInfo extension MultiplexerSession { @@ -92,6 +206,8 @@ extension MultiplexerSession { isAttached: false, detail: session.detailText, subtitle: session.agentSubtitle, + // Raw-multiplexer bound like tmux; see the note there. + workingDirectory: nil, capturedContent: session.capturedContent, isExited: !session.isRunning ) diff --git a/rootshell/Features/SSH/Views/SSHConnectionView.swift b/rootshell/Features/SSH/Views/SSHConnectionView.swift index aeae1888..623b6697 100644 --- a/rootshell/Features/SSH/Views/SSHConnectionView.swift +++ b/rootshell/Features/SSH/Views/SSHConnectionView.swift @@ -90,6 +90,9 @@ struct SSHConnectionView: View { // herdr auto-attach (mutually exclusive with enableTmux via the picker) @State private var enableHerdr: Bool = false + // zmx auto-attach (mutually exclusive with the others via the picker) + @State private var enableZmx: Bool = false + // Launch command @State private var launchCommand: String = "" @State private var launchCommandMode: SSHConfig.LaunchCommandMode = .afterConnect @@ -498,6 +501,11 @@ struct SSHConnectionView: View { isTerminalOptionsExpanded = true } } + .onChange(of: enableZmx) { _, enabled in + if enabled { + isTerminalOptionsExpanded = true + } + } .onChange(of: launchCommand) { _, newValue in if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { isTerminalOptionsExpanded = true @@ -1089,15 +1097,17 @@ struct SSHConnectionView: View { connectionProtocol == .mosh ? .regular : tmuxAutoMode } - /// Bridges the stored `(enableTmux, tmuxAutoMode, enableHerdr)` fields to a + /// Bridges the stored `(enableTmux, tmuxAutoMode, enableHerdr, enableZmx)` fields to a /// single Picker. Control mode can't run over Mosh, so it's presented as /// regular there. private var tmuxLaunchSelection: Binding { Binding( - get: { TmuxLaunchSelection(tmuxEnabled: enableTmux, mode: effectiveTmuxAutoMode, herdrEnabled: enableHerdr) }, + get: { TmuxLaunchSelection(tmuxEnabled: enableTmux, mode: effectiveTmuxAutoMode, + herdrEnabled: enableHerdr, zmxEnabled: enableZmx) }, set: { sel in enableTmux = sel.tmuxEnabled enableHerdr = sel.herdrEnabled + enableZmx = sel.zmxEnabled if sel.tmuxEnabled { tmuxAutoMode = sel.mode } } ) @@ -1112,6 +1122,7 @@ struct SSHConnectionView: View { Text("tmux -CC (control)").tag(TmuxLaunchSelection.control) } Text("herdr").tag(TmuxLaunchSelection.herdr) + Text("zmx").tag(TmuxLaunchSelection.zmx) } .pickerStyle(.menu) @@ -1127,6 +1138,10 @@ struct SSHConnectionView: View { Text("Attach to or create herdr session \"\(multiplexerCaptionSessionName)\" on connect") .font(.caption) .foregroundColor(.secondary) + } else if enableZmx { + Text("Attach to or create zmx session \"\(multiplexerCaptionSessionName)\" on connect") + .font(.caption) + .foregroundColor(.secondary) } VStack(alignment: .leading, spacing: 4) { @@ -1551,6 +1566,16 @@ struct SSHConnectionView: View { return String(localized: "herdr attaches automatically") } + if enableZmx && !trimmedCommand.isEmpty { + return launchCommandMode == .initialCommandWithPTY + ? String(localized: "Initial command with PTY") + : String(localized: "zmx enabled, launch command configured") + } + + if enableZmx { + return String(localized: "zmx attaches automatically") + } + if !trimmedCommand.isEmpty { return launchCommandMode == .initialCommandWithPTY ? String(localized: "Initial command with PTY") @@ -1666,6 +1691,7 @@ struct SSHConnectionView: View { enableTmux = config.tmuxAutoEnable tmuxAutoMode = config.tmuxAutoMode enableHerdr = config.herdrAutoEnable + enableZmx = config.zmxAutoEnable launchCommand = config.launchCommand ?? "" launchCommandMode = config.launchCommandMode restoreCarriedOverrides(terminalType: config.terminalType, @@ -1881,6 +1907,7 @@ struct SSHConnectionView: View { tmuxAutoEnable: enableTmux ? true : nil, tmuxAutoMode: enableTmux ? effectiveTmuxAutoMode : nil, herdrAutoEnable: enableHerdr ? true : nil, + zmxAutoEnable: enableZmx ? true : nil, launchCommand: launchCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : launchCommand.trimmingCharacters(in: .whitespacesAndNewlines), launchCommandMode: launchCommandMode, terminalType: terminalTypeOverride( @@ -2071,6 +2098,7 @@ struct SSHConnectionView: View { ) config.fallbackKeyIDs = fallbackKeyIDs config.herdrAutoEnable = enableHerdr + config.zmxAutoEnable = enableZmx // Apply the GPG agent config after the SSHConfig is built — // the convenience initializers above don't carry it. @@ -2554,6 +2582,7 @@ struct SSHConnectionView: View { enableTmux = entry.tmuxAutoEnable ?? false tmuxAutoMode = entry.tmuxAutoMode ?? .regular enableHerdr = entry.herdrAutoEnable ?? false + enableZmx = entry.zmxAutoEnable ?? false // Restore launch command if present launchCommand = entry.launchCommand ?? "" @@ -2685,6 +2714,7 @@ struct SSHConnectionView: View { enableTmux = config.tmuxAutoEnable tmuxAutoMode = config.tmuxAutoMode enableHerdr = config.herdrAutoEnable + enableZmx = config.zmxAutoEnable // Set launch command launchCommand = config.launchCommand ?? "" diff --git a/rootshell/Features/Tmux/Settings/MultiplexerSettingsView.swift b/rootshell/Features/Tmux/Settings/MultiplexerSettingsView.swift index 5b2f1a3a..cebfe41a 100644 --- a/rootshell/Features/Tmux/Settings/MultiplexerSettingsView.swift +++ b/rootshell/Features/Tmux/Settings/MultiplexerSettingsView.swift @@ -4,6 +4,7 @@ struct MultiplexerSettingsView: View { @AppStorage("tmuxSessionDiscoveryEnabled") private var tmuxDiscoveryEnabled = true @AppStorage("zellijSessionDiscoveryEnabled") private var zellijDiscoveryEnabled = true @AppStorage("herdrSessionDiscoveryEnabled") private var herdrDiscoveryEnabled = true + @AppStorage("zmxSessionDiscoveryEnabled") private var zmxDiscoveryEnabled = true @AppStorage("localSessionDiscoveryEnabled") private var localDiscoveryEnabled = true @AppStorage(SessionDiscoverySortOrder.storageKey) private var sortOrder = SessionDiscoverySortOrder.attachedFirst.rawValue @AppStorage(TabExposeSettings.multiplexerEnabledKey) private var exposeMultiplexerEnabled = true @@ -11,6 +12,8 @@ struct MultiplexerSettingsView: View { @AppStorage("tmuxCustomCommand") private var customCommand = "" @AppStorage("herdrSessionName") private var herdrSessionName = "" @AppStorage("herdrCustomCommand") private var herdrCustomCommand = "" + @AppStorage("zmxSessionName") private var zmxSessionName = "" + @AppStorage("zmxCustomCommand") private var zmxCustomCommand = "" @AppStorage(TmuxController.autoHideGatewayOnAttachDefaultsKey) private var autoHideGatewayOnAttach = false @AppStorage(TmuxTabCloseAction.storageKey) @@ -38,6 +41,16 @@ struct MultiplexerSettingsView: View { return name.isEmpty ? "default" : name } + private var zmxAutoStartCommandSummary: String { + if !zmxCustomCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "Custom" + } + let name = zmxSessionName.trimmingCharacters(in: .whitespacesAndNewlines) + // Unlike herdr there is no unnamed default session to fall back to, so + // the placeholder is a real session name. + return name.isEmpty ? SSHConfig.zmxDefaultSessionName : name + } + private var discoveryFooterText: String { let base = "Checks for active sessions after an SSH connection. Skipped for connections with multiplexer auto-start enabled." #if targetEnvironment(macCatalyst) @@ -52,7 +65,7 @@ struct MultiplexerSettingsView: View { Section { Toggle(isOn: $tmuxDiscoveryEnabled) { HStack(spacing: 12) { - SettingsIcon(systemName: "rectangle.split.2x1") + SettingsIcon(systemName: MultiplexerType.tmux.iconName) Text("Discover tmux Sessions") } } @@ -60,7 +73,7 @@ struct MultiplexerSettingsView: View { Toggle(isOn: $zellijDiscoveryEnabled) { HStack(spacing: 12) { - SettingsIcon(systemName: "rectangle.split.3x1") + SettingsIcon(systemName: MultiplexerType.zellij.iconName) Text("Discover zellij Sessions") } } @@ -68,12 +81,20 @@ struct MultiplexerSettingsView: View { Toggle(isOn: $herdrDiscoveryEnabled) { HStack(spacing: 12) { - SettingsIcon(systemName: "square.grid.2x2") + SettingsIcon(systemName: MultiplexerType.herdr.iconName) Text("Discover herdr Sessions") } } .themedRow() + Toggle(isOn: $zmxDiscoveryEnabled) { + HStack(spacing: 12) { + SettingsIcon(systemName: MultiplexerType.zmx.iconName) + Text("Discover zmx Sessions") + } + } + .themedRow() + #if targetEnvironment(macCatalyst) Toggle(isOn: $localDiscoveryEnabled) { HStack(spacing: 12) { @@ -197,11 +218,31 @@ struct MultiplexerSettingsView: View { Text("The herdr command used when auto-start is enabled on a connection.") } + Section { + NavigationLink { + ZmxAutoStartCommandView() + } label: { + HStack(spacing: 12) { + SettingsIcon(systemName: "play.rectangle") + Text("Auto-Start Command") + Spacer() + Text(zmxAutoStartCommandSummary) + .foregroundColor(.secondary) + .font(.subheadline) + } + } + .themedRow() + } header: { + Text("zmx Auto-Start") + } footer: { + Text("The zmx command used when auto-start is enabled on a connection.") + } + Section { NavigationLink { TmuxGuideView() } label: { - Label("tmux Tips", systemImage: "questionmark.circle") + Label("Multiplexer Tips", systemImage: "questionmark.circle") } .themedRow() } diff --git a/rootshell/Features/Tmux/Settings/ZmxAutoStartCommandView.swift b/rootshell/Features/Tmux/Settings/ZmxAutoStartCommandView.swift new file mode 100644 index 00000000..3c755363 --- /dev/null +++ b/rootshell/Features/Tmux/Settings/ZmxAutoStartCommandView.swift @@ -0,0 +1,111 @@ +// +// ZmxAutoStartCommandView.swift +// rootshell +// +// Configures the zmx command used by per-connection auto-start: the +// session name, an optional full command override, and a read-only +// preview of the effective command. +// +// Deliberately a copy of `HerdrAutoStartCommandView` rather than a shared +// parameterised view. The three auto-start screens differ only in two +// `@AppStorage` keys, a placeholder, four strings and the effective-command +// source, so a parameterised version would be shorter — but it would also +// rewrite two already-shipped settings screens. Noted as a follow-up instead. +// + +import SwiftUI + +struct ZmxAutoStartCommandView: View { + @AppStorage("zmxSessionName") private var sessionName = "" + @AppStorage("zmxCustomCommand") private var customCommand = "" + @State private var copied = false + @FocusState private var isEditorFocused: Bool + + private var hasCustomCommand: Bool { + !customCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + Form { + Section { + // Placeholder is a real session name, not "default": zmx has no + // unnamed default session, so an empty field means "main". + TextField(SSHConfig.zmxDefaultSessionName, text: $sessionName) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .disabled(hasCustomCommand) + .foregroundStyle(hasCustomCommand ? .secondary : .primary) + .themedRow() + } header: { + Text("Session Name") + } footer: { + if hasCustomCommand { + Text("Ignored when a custom command is set.") + } else { + Text("The zmx session name used by auto-start. Defaults to \"main\" if empty.") + } + } + + Section { + TextEditor(text: $customCommand) + .font(.system(.body, design: .monospaced)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .focused($isEditorFocused) + .frame(minHeight: 80) + .themedRow() + + if hasCustomCommand { + Button("Clear Custom Command", role: .destructive) { + customCommand = "" + } + .themedRow() + } + } header: { + Text("Custom Command") + } footer: { + Text("Overrides the entire zmx command, including the session name. Leave empty to use the default.") + } + + Section { + HStack(alignment: .top) { + Text(SSHConfig.zmxExecCommand) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + UIPasteboard.general.string = SSHConfig.zmxExecCommand + copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + copied = false + } + } label: { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + .foregroundStyle(copied ? .green : .accentColor) + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.borderless) + } + .themedRow() + } header: { + Text("Effective Command") + } footer: { + Text("This command runs when zmx auto-start is enabled on a connection. zmx attaches to the session if it is running, or starts it.") + } + } + .themedList() + #if !os(visionOS) + .scrollDismissesKeyboard(.interactively) + #endif + .navigationTitle("Auto-Start Command") + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + NavigationView { + ZmxAutoStartCommandView() + } +} diff --git a/rootshell/UI/Overlays/SessionPickerOverlay.swift b/rootshell/UI/Overlays/SessionPickerOverlay.swift index a2002187..55db532f 100644 --- a/rootshell/UI/Overlays/SessionPickerOverlay.swift +++ b/rootshell/UI/Overlays/SessionPickerOverlay.swift @@ -2,7 +2,7 @@ // SessionPickerOverlay.swift // rootshell // -// Overlay for selecting an active multiplexer session (tmux/zellij/herdr) after SSH connection. +// Overlay for selecting an active multiplexer session (tmux/zellij/herdr/zmx) after SSH connection. // import SwiftUI @@ -29,13 +29,12 @@ struct SessionPickerOverlay: View { } private var headerIcon: String { - if sessionTypes.count == 1, sessionTypes.contains(.zellij) { - return "rectangle.split.2x1" - } - if sessionTypes.count == 1, sessionTypes.contains(.herdr) { - return "square.grid.2x2" + // Single-type pickers show that multiplexer's own icon; mixed pickers + // fall back to tmux's, which reads as a generic "panes" glyph. + if sessionTypes.count == 1, let type = sessionTypes.first { + return type.iconName } - return "rectangle.split.3x1" + return MultiplexerType.tmux.iconName } private var isMixed: Bool { sessionTypes.count > 1 } diff --git a/rootshell/UI/Settings/Guides/TmuxGuideView.swift b/rootshell/UI/Settings/Guides/TmuxGuideView.swift index f8a81278..8aa0ab55 100644 --- a/rootshell/UI/Settings/Guides/TmuxGuideView.swift +++ b/rootshell/UI/Settings/Guides/TmuxGuideView.swift @@ -3,8 +3,9 @@ // rootshell // // Explains tmux -CC control mode (gateway tab, auto-hide, tab shortcuts) -// and shows the recommended ~/.tmux.conf lines, so the Multiplexers screen -// can keep its footers short. +// and zmx's detach key and attach-or-create behaviour, and shows the +// recommended ~/.tmux.conf lines, so the Multiplexers screen can keep its +// footers short. // import SwiftUI @@ -52,6 +53,37 @@ struct TmuxGuideView: View { Text("Control Mode") } + // MARK: - zmx + Section { + VStack(alignment: .leading, spacing: 12) { + guideRow( + icon: "arrow.right.square", + title: "Detaching", + description: "Press ctrl+\\ to detach from a zmx session and return to the shell. Set ZMX_NO_DETACH_KEY=1 on the host to disable that key if it conflicts with something you use." + ) + + Divider() + + guideRow( + icon: "plus.square.on.square", + title: "Attach or Create", + description: "zmx attach joins the session if it exists and creates it otherwise, so auto-start never fails on a fresh host. There is no unnamed default session, so a name is always used — \"main\" unless you set another." + ) + + Divider() + + guideRow( + icon: "arrow.up.left.and.arrow.down.right", + title: "Resizing", + description: "A zmx session has one size shared by every attached client, and the last client to type sets it. Attaching from rootshell will reflow the session for anyone else attached as soon as you type. This is how zmx works, not a rootshell limitation." + ) + } + .padding(.vertical, 4) + .themedRow() + } header: { + Text("zmx") + } + // MARK: - Recommended Configuration Section { Text("Add these lines to enable mouse support and pass window titles through to rootshell tab titles.") @@ -87,7 +119,7 @@ struct TmuxGuideView: View { } } .themedList() - .navigationTitle("tmux Tips") + .navigationTitle("Multiplexer Tips") .navigationBarTitleDisplayMode(.inline) } diff --git a/rootshell/UI/Terminal/TerminalView+Session.swift b/rootshell/UI/Terminal/TerminalView+Session.swift index 11215f09..1e4501b3 100644 --- a/rootshell/UI/Terminal/TerminalView+Session.swift +++ b/rootshell/UI/Terminal/TerminalView+Session.swift @@ -474,9 +474,27 @@ extension Ghostty.TerminalView { /// multiplexer is still running, so the binding must survive those paths. /// (id=agent-attention-raw-mux) func applyConfiguredMultiplexerBinding() { - guard rawMultiplexer == nil else { return } guard let sshConfig = connectionConfig.sshConfigForHistory else { return } + // The passthrough slot is independent of the raw one, so this is + // recorded first and WITHOUT returning: `zmxAutoEnable` plus a + // `launchCommand` that starts tmux is a supported and natural + // configuration — zmx has no splits or windows, so a user wanting real + // multiplexing on top of it configures exactly that — and both facts + // are true of the pane at once. Returning here would leave the real, + // alt-screen-owning tmux unbound and therefore unsuppressed. + // + // A nil name means `zmxCustomCommand` replaced the whole command line + // and the session name is not knowable without parsing arbitrary + // shell. Declining is the honest outcome: the exposé's focus sends a + // switch on the socket of whatever name we claim, so a guessed name + // would move a session this pane is not in. + if sshConfig.zmxAutoEnable, let name = sshConfig.zmxSessionNameForConnection { + bindPassthroughMultiplexer(.zmx, sessionName: name) + } + + guard rawMultiplexer == nil else { return } + // Auto-connect. Control mode gets its own surface per pane, so only // the plain mode collapses a whole session onto this one. if sshConfig.tmuxAutoEnable, sshConfig.tmuxAutoMode == .regular { @@ -502,12 +520,42 @@ extension Ghostty.TerminalView { /// Sets the binding and re-runs monitor reconcile, so a pane that already /// published an agent card before the multiplexer was known drops it. + /// + /// Refuses a passthrough. This slot is read as "suppress agent detection + /// here", and its only release path — `AgentAttentionCenter`'s + /// alt-screen-ownership-then-lapse check — never fires for a multiplexer + /// that does not own the screen, so a passthrough recorded here would + /// suppress agent detection for the life of the connection. The guard + /// makes that unrepresentable rather than leaving it to every call site to + /// remember; `bindPassthroughMultiplexer` is the other half. + /// (id=agent-attention-raw-mux) func bindRawMultiplexer(_ type: MultiplexerType, sessionName: String?) { + guard type.ownsAlternateScreen else { return } guard rawMultiplexer == nil else { return } rawMultiplexer = .init(type: type, sessionName: sessionName) AgentAttentionCenter.shared.topologyDidChange() } + /// Records a transparent passthrough — identity for the exposé, with no + /// effect on agent attention. + /// + /// Deliberately does NOT call `topologyDidChange()`: nothing about agent + /// attention changes, because nothing about agent attention reads this + /// slot. That is the whole point of it being a separate slot. + /// + /// `sessionName` is not optional here, unlike `bindRawMultiplexer`. A raw + /// binding can fall back on `resolveSessionScript`, which answers only + /// when the host has exactly one session — and for a passthrough "the only + /// session on the host" is not the same fact as "the session this pane is + /// attached to". Both callers know the name, so requiring it costs nothing + /// and makes a nameless passthrough unrepresentable. + /// (id=agent-attention-raw-mux) + func bindPassthroughMultiplexer(_ type: MultiplexerType, sessionName: String) { + guard !type.ownsAlternateScreen else { return } + guard passthroughMultiplexer == nil else { return } + passthroughMultiplexer = .init(type: type, sessionName: sessionName) + } + /// Maps a configured command to the multiplexer it starts, or nil when it /// starts none. Matches the command word only — an explicit table, never a /// substring search, so an unrelated command mentioning "tmux" is not a @@ -544,6 +592,7 @@ extension Ghostty.TerminalView { let sshConfig = connectionConfig.sshConfigForHistory let multiplexerAutoEnabled = (sshConfig?.tmuxAutoEnable ?? false) || (sshConfig?.herdrAutoEnable ?? false) + || (sshConfig?.zmxAutoEnable ?? false) let launchCommand = sshConfig?.launchCommand // Skip for resumed sessions - the remote shell already has tmux/commands running @@ -614,7 +663,7 @@ extension Ghostty.TerminalView { } } - /// Discovers multiplexer sessions (tmux, zellij, herdr) on the active host or local shell. + /// Discovers multiplexer sessions (tmux, zellij, herdr, zmx) on the active host or local shell. /// Called after the session reports it is ready for input. func discoverSessionsIfConfigured() { // Check which discoveries are enabled (default to true for all) @@ -624,8 +673,10 @@ extension Ghostty.TerminalView { || UserDefaults.standard.bool(forKey: "zellijSessionDiscoveryEnabled") let herdrEnabled = UserDefaults.standard.object(forKey: "herdrSessionDiscoveryEnabled") == nil || UserDefaults.standard.bool(forKey: "herdrSessionDiscoveryEnabled") + let zmxEnabled = UserDefaults.standard.object(forKey: "zmxSessionDiscoveryEnabled") == nil + || UserDefaults.standard.bool(forKey: "zmxSessionDiscoveryEnabled") - guard tmuxEnabled || zellijEnabled || herdrEnabled else { return } + guard tmuxEnabled || zellijEnabled || herdrEnabled || zmxEnabled else { return } #if STANDALONE && targetEnvironment(macCatalyst) if session is CatalystLocalShellSession, case .local = connectionConfig { @@ -637,6 +688,7 @@ extension Ghostty.TerminalView { let skipTmuxSessions = !tmuxEnabled let skipZellijSessions = !zellijEnabled let skipHerdrSessions = !herdrEnabled + let skipZmxSessions = !zmxEnabled let discoverTmuxBindings = tmuxEnabled let discoverZellijBindings = zellijEnabled let workingDirectory = connectionConfig.workingDirectory @@ -647,6 +699,7 @@ extension Ghostty.TerminalView { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: discoverTmuxBindings, discoverZellijBindings: discoverZellijBindings ) @@ -666,16 +719,18 @@ extension Ghostty.TerminalView { // multiplexer types: the connection is already committed to a // multiplexer, so sessions of another type must not pop the picker // over its freshly started UI. - let multiplexerAutoStart = sshConfig.tmuxAutoEnable || sshConfig.herdrAutoEnable + let multiplexerAutoStart = sshConfig.tmuxAutoEnable || sshConfig.herdrAutoEnable || sshConfig.zmxAutoEnable let allowSessionPickerOverlay = !hasLaunchCommand && !hasRemoteCommand && !wasResumed && !multiplexerAutoStart let skipTmuxSessions = !tmuxEnabled || !allowSessionPickerOverlay let skipZellijSessions = !zellijEnabled || !allowSessionPickerOverlay let skipHerdrSessions = !herdrEnabled || !allowSessionPickerOverlay + let skipZmxSessions = !zmxEnabled || !allowSessionPickerOverlay let discoverTmuxBindings = tmuxEnabled let discoverZellijBindings = zellijEnabled - guard discoverTmuxBindings || discoverZellijBindings || !skipTmuxSessions || !skipZellijSessions || !skipHerdrSessions else { + guard discoverTmuxBindings || discoverZellijBindings || !skipTmuxSessions + || !skipZellijSessions || !skipHerdrSessions || !skipZmxSessions else { return } @@ -707,6 +762,7 @@ extension Ghostty.TerminalView { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: discoverTmuxBindings, discoverZellijBindings: discoverZellijBindings ) @@ -719,6 +775,7 @@ extension Ghostty.TerminalView { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: discoverTmuxBindings, discoverZellijBindings: discoverZellijBindings, onKeyboardInteractiveChallenge: discoveryKeyboardInteractive @@ -813,6 +870,85 @@ extension Ghostty.TerminalView { // server. "default" is the literal name of the default session. attachCommand = "herdr session attach \"\(escapedName)\"" bindRawMultiplexer(.herdr, sessionName: session.name) + case .zmx: + // Attach-or-create. zmx has no default-session concept, so the name + // is always explicit. + // + // `bindPassthroughMultiplexer` rather than `bindRawMultiplexer`, + // unlike all three cases above. The raw slot is a suppression + // switch for multiplexers that own the alternate screen for the + // whole attach and keep their own status bar fresh; it disables + // agent project identity (`AgentPaneMonitor.noteProject`) and the + // working -> idle completion edge that produces "Done" + // notifications. zmx is a transparent PTY passthrough: no windows, + // no status bar, and `serializeTerminalState` deliberately does not + // leak the alternate screen. Recording it there would permanently + // kill agent detection on this surface -- permanently because that + // binding is only released after alt-screen ownership is observed + // and then lost, which for zmx never happens. + // + // It would also cancel out the reason this works at all: zmx passes + // OSC 7 through untouched, so `terminal.pwd` gives the agent card its + // project for free -- but `noteProject` returns false before it ever + // looks at the pwd when a raw binding is active. + // + // The passthrough slot carries the same fact without any of that: + // the exposé reads it for identity, and nothing in agent attention + // reads it at all. See `MultiplexerType.ownsAlternateScreen`. + // + // `ZMX_SESSION_PREFIX=` is not cosmetic. Every zmx subcommand except + // `list` routes its name argument through `socket.getSeshName` + // (`socket.zig`), which PREPENDS `$ZMX_SESSION_PREFIX`. `zmx + // list` does not -- it enumerates the socket directory and reports + // real, fully-resolved socket names. So with the prefix set on the + // remote, discovering `api` and sending `zmx attach "api"` resolves + // to `zz_api`, and since attach is attach-or-CREATE it silently + // mints a brand new empty session instead of failing. Neutralising + // the prefix is the correct fix rather than the expedient one: the + // name in hand is already the real socket name, so re-applying a + // prefix to it is always wrong. The same neutralisation is applied + // to `zmx history` in `SessionDiscoveryRunner` (see the fuller note + // there: the preview silently shows the wrong session, or nothing at + // all, and never an error) and to the auto-start line in + // `SSHConfig.zmxExecCommandLine`. + // + // The variable reaches us at all because `sendUserInput` types this + // into the interactive shell, which sources `~/.bashrc` in full. + attachCommand = "ZMX_SESSION_PREFIX= zmx attach \"\(escapedName)\"" + bindPassthroughMultiplexer(.zmx, sessionName: session.name) + + // Seed the pane's working directory from the one `zmx list` already + // reported, as an opening value that OSC 7 then supersedes. + // + // OSC 7 is the authority. Current zmx replays it on attach -- + // `serializeTerminalState` calls `writePwd` (util.zig), covered by + // its own "replays the pwd without a NUL sentinel" test -- and that + // reaches us: Ghostty parses OSC 7 out of the stream and dispatches + // `GHOSTTY_ACTION_PWD` to `handlePwdChange` for any surface, + // regardless of transport (`GhosttyApp.swift`). The replayed value + // is live and arrives moments after this line, so it wins on + // ordering and keeps winning as the shell reports again. + // + // The seed is what covers the gap before that lands, and the hosts + // where it never does: + // - zmx v0.7.0 reports `start_dir=` and has no replay at all, so + // the startup directory is genuinely all there is. + // - Until the replay arrives the pane has no directory, and the + // agent card would name no project for that window. + // `SSHSession`/`CitadelSSHSession` never fire + // `onWorkingDirectoryChange` -- only `LocalShellSession` does -- so + // OSC 7 is an SSH pane's only live source and the seed its only + // fallback. + // + // `handlePwdChange` rather than assigning `pwd` directly: it also + // fills the background-safe `sessionProvidedPwd` cache and notifies + // `AgentAttentionCenter`. Setting `terminal.pwd` specifically (rather + // than the monitor's project) is what makes it survive agent + // re-identification -- `resetProjectForNewAgent` clears the project + // and immediately re-resolves from `terminal.pwd`. + if let cwd = session.workingDirectory, cwd.hasPrefix("/") { + handlePwdChange(cwd) + } } let script = "\(pathPrefix) \(attachCommand)" let command = "sh -c '\(shellEscapeForSingleQuotes(script))'\n" diff --git a/rootshell/UI/Terminal/TerminalView+SessionHost.swift b/rootshell/UI/Terminal/TerminalView+SessionHost.swift index 070501af..bca1cf60 100644 --- a/rootshell/UI/Terminal/TerminalView+SessionHost.swift +++ b/rootshell/UI/Terminal/TerminalView+SessionHost.swift @@ -71,6 +71,20 @@ extension Ghostty.TerminalView: TerminalSessionControllerHost { // Cancel connection success timer if session ends prematurely. self.sessionController.cancelConnectionSuccessTimer() + // A passthrough binding is released by connection teardown and by + // nothing else. It cannot use the alt-screen-lapse release that frees a + // `rawMultiplexer`, because a passthrough never takes the alternate + // screen in the first place -- see `MultiplexerType.ownsAlternateScreen`. + // + // Cleared before the reconnection early-returns below, not after: on a + // reconnect the pane comes back at a fresh shell prompt, attached to + // nothing, and `applyConfiguredMultiplexerBinding` re-records the + // binding from configuration if there is one to record. A binding that + // came from the session picker instead has no configuration behind it, + // so it must not survive -- the exposé would otherwise offer to switch + // a session this pane is no longer in. (id=agent-attention-raw-mux) + self.passthroughMultiplexer = nil + // Check if reconnection manager is in a state that should keep tab open if let state = self.sessionController.reconnectionState { switch state { diff --git a/rootshell/UI/Terminal/TerminalView.swift b/rootshell/UI/Terminal/TerminalView.swift index 53aba616..6078fd1b 100644 --- a/rootshell/UI/Terminal/TerminalView.swift +++ b/rootshell/UI/Terminal/TerminalView.swift @@ -379,6 +379,26 @@ extension Ghostty { var hasOwnedAltScreen: Bool = false } var rawMultiplexer: RawMultiplexerBinding? + + /// A multiplexer this pane is attached to that does NOT take the + /// alternate screen and does NOT mask the inner program from the + /// surface: identity for the exposé, without being a suppression + /// signal for agent attention. + /// + /// Deliberately not `rawMultiplexer`. That slot means two things at + /// once — "this pane is inside multiplexer X" and "suppress agent + /// detection here" — and a passthrough wants the first without the + /// second. Keeping them apart leaves every agent-detection read site + /// meaning exactly what it means today, rather than teaching the + /// shared slot to tell the two apart and then having to keep four + /// bind sites honest about which it meant. + /// + /// `hasOwnedAltScreen` is unused here: nothing maintains it for this + /// slot, and nothing reads it. A passthrough is released by connection + /// teardown, never by a screen transition — see + /// `MultiplexerType.ownsAlternateScreen`. + /// (id=agent-attention-raw-mux) + var passthroughMultiplexer: RawMultiplexerBinding? nonisolated(unsafe) var tmuxDetachInProgressAtomic: Bool = false var isTmuxDetachInProgress: Bool { @@ -4516,7 +4536,12 @@ extension Ghostty.TerminalView: GhosttyActionDelegate { paneUUID: uuid, exitCode: exitCode, duration: duration) } - func handlePwdChange(_ pwd: String) { + func handlePwdChange(_ reported: String) { + // OSC 7 reports `file://`, and Ghostty + // passes the payload through verbatim. Decode here, at the one point + // every pwd source funnels through, so no caller has to know which + // shape it was handed; a bare path is returned untouched. + let pwd = WorkingDirectoryURI.path(reported) // Always cache so we don't lose the latest value while backgrounded. self.sessionProvidedPwd = pwd guard !Ghostty.isAppBackgroundedAtomic else { return }