From 030e25f1100ebb4473870648c66e3e05c6c2c04a Mon Sep 17 00:00:00 2001 From: Lakr233 Date: Tue, 20 Oct 2026 19:12:01 +0000 Subject: [PATCH] Keep paired devices while remote access is off, and write the 1.6.17 note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remote access off no longer hides the authorized devices: the daemon starts ighostvtd-remote dormant (no listener, no Bonjour, no relay) to answer status, revoke and rename, and it exits after a minute unasked. - ⌘T on a remote tab opens in that tab's directory on the same device and goes right after it; ⌘N and every other new tab are appended. - Copy as One Line joins rows a program broke between words with a space and closes up a path cut at the terminal's edge (OneLineText, tested). - A tab's second line and the Live Activity row show the title and the last component of the directory, not the process name. - Recent directories in the new-tab menu show the folder name alone. - Background Activities' footer says sessions keep running after quit. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 40 ++++-- Documents/Releases/1.6.17.md | 10 ++ Makefile | 5 + Shared/Wire/IOWire.swift | 3 + Tests/OneLineText/main.swift | 118 ++++++++++++++++ iGhostVT/Application/AppMenus.swift | 6 +- iGhostVT/Application/TerminalWindow.swift | 10 +- .../SessionActivityController.swift | 9 +- .../Session/LockableTerminalView.swift | 21 +-- iGhostVT/Backend/Session/OneLineText.swift | 130 ++++++++++++++++++ iGhostVT/Backend/Session/TabManager.swift | 68 +++++---- .../Backend/Session/TerminalDirectory.swift | 6 - .../Session/TerminalSessionStore.swift | 42 ++---- iGhostVT/Backend/Session/TerminalTab.swift | 47 +++---- .../Settings/Mac/MacSettingsPanes.swift | 11 +- .../Settings/Remote/RemoteAccessModel.swift | 7 + .../Settings/Remote/RemoteAccessView.swift | 17 ++- .../Sections/SessionSettingsSection.swift | 4 +- iGhostVT/Interface/Support/NewTabMenu.swift | 10 +- .../Support/NewTabMenuElements.swift | 11 +- iGhostVT/Interface/Support/TabLabels.swift | 4 +- iGhostVT/Resources/Localizable.xcstrings | 56 ++++---- iGhostVTDaemon/Server/RemoteSupervisor.swift | 34 ++++- iGhostVTRemote/RemoteService.swift | 74 ++++++++-- iGhostVTRemote/main.swift | 8 +- iGhostVTWidgets/LockScreenView.swift | 29 ++-- 26 files changed, 554 insertions(+), 226 deletions(-) create mode 100644 Documents/Releases/1.6.17.md create mode 100644 Tests/OneLineText/main.swift create mode 100644 iGhostVT/Backend/Session/OneLineText.swift diff --git a/AGENTS.md b/AGENTS.md index c236b461..d1d0499a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,8 +22,12 @@ which launchd never sized — so a session's buffers cannot jetsam the daemon. - **The app never spawns processes.** `ighostvtd-io` forks the shells (`forkpty`+`execve`), and on the Mac the three tools its update needs (`ditto`, `xattr`, `open`; `MacUpdater`); `ighostvtd` spawns only its two - children — `ighostvtd-io` (`IOSupervisor`) and, while remote access is - on, `ighostvtd-remote` (`RemoteSupervisor`) — via `posix_spawn`. Peer + children — `ighostvtd-io` (`IOSupervisor`) and `ighostvtd-remote` + (`RemoteSupervisor`), which runs while remote access is on, and dormant + (`--dormant`: no listener, no Bonjour, no relay; gone after a minute + unasked) while it is off and the app asks about the paired devices, so + turning the switch off stops them connecting and forgets nobody — the + devices stay listed and removable — via `posix_spawn`. Peer authentication is still the daemon's, gated by the kernel audit token before a byte is forwarded. Keep that boundary; don't add process APIs to the app or to the proxy. @@ -65,7 +69,12 @@ which launchd never sized — so a session's buffers cannot jetsam the daemon. (`onTextSelectionRequest`) the app's own selection sheet hung off, so the app has no selection UI of its own; customise the menus through `touchMenuItems(for:)` / `touchSelectionMenuItems(for:)` on - `LockableTerminalView`, never by bringing a sheet back. Match an item + `LockableTerminalView`, never by bringing a sheet back. Copy as One Line + is `OneLineText.join` (tested by `Tests/OneLineText`): a program that lays + out its own text drops the space where it breaks a row, so a row that + stops short of the terminal's width, or ends padded, joins with a space, + and one that fills it closes up only when the word across the break would + not have fit a row — a cut path or link, not a word boundary. Match an item there by its identifier (`terminal.copy`, `terminal.paste`, …), never by its title: since 2.2.2026100701 the titles are localized, and they follow the user's language only because `Info.plist` sets @@ -750,7 +759,8 @@ is cut and reconnects as after any interruption. Both plists say `RunAtLoad` + `KeepAlive = {SuccessfulExit = false}` + `MachServices`: a crash restarts, the idle exit stands, the next lookup demand-launches, and `make check` holds both to it. While remote access is on, -`ighostvtd-remote` keeps one connection open for as long as it runs, so the +`ighostvtd-remote` keeps one connection open for as long as it runs (a +dormant one holds none), so the daemon is resident exactly as long as the switch is on — the daemon itself knows nothing about it. `shutdown` stays in the protocol for any client that wants the same exit sooner. @@ -996,10 +1006,13 @@ Tab titles have three sources. The daemon's is the one always there: each session polls `tcgetpgrp` on its PTY (and re-checks as output drains, rate limited — the drain sees one check per 64 KiB otherwise), resolves the foreground process group leader's `proc_name`, and pushes it as -event 102 — also stated in every open/attach reply — so -`TerminalTab.secondaryTitle`, the dim line under the title, is a short stable -name ("zsh", "vim", "grok") no matter how often the program retitles, and a -session that reports nothing is titled by it. Ghostty's shell integration is the +event 102 — also stated in every open/attach reply — and a session that +reports nothing is titled by it. It is never the dim line beside the title +(`TerminalTab.secondaryTitle`): some programs' binary is only a version +number (Claude Code's reads `2.1.304`), so that line is the last component +of the tab's directory (`displayDirectory`, the same one the Live Activity +lists, whose row reads title then that component too), plus the device's +name for a remote tab. Ghostty's shell integration is the second: the daemon injects it (`ShellIntegration`) and the .deb ships libghostty's own scripts to `/usr/share/ighostvt/shell-integration`, so the shell reports OSC 2 (command), OSC 7 (cwd), OSC 133 (prompts) by itself. @@ -1139,11 +1152,12 @@ there too, given the touch when the finger held still to 0.6 s, and on an iPad it opened under a reorder that had not moved yet; it is gone, not retimed. -A tab that copies the active one — ⌘T, or a menu row naming the active -tab's own device and directory — goes right after it, as a browser's -does; every other new tab — another directory, another device's -terminal, a Shortcut, a URL, a moved session — is appended -(`TabManager.opensBesideActiveTab`). +⌘T copies the active tab — its device, and its directory (a remote tab +names its session to the far daemon and its last reported directory as +the fallback) — and goes right after it, as a browser's does. Every other +new tab — ⌘N (this device's home), a menu row, another device's terminal, +a Shortcut, a URL, a moved session — is appended +(`TabManager.newTab(_:besideActiveTab:)`). Every `+` is a `NewTabMenu`: the only decision a new terminal has is where its shell starts, so the control opens a menu of directories instead of a diff --git a/Documents/Releases/1.6.17.md b/Documents/Releases/1.6.17.md new file mode 100644 index 00000000..f8da30fd --- /dev/null +++ b/Documents/Releases/1.6.17.md @@ -0,0 +1,10 @@ +iGhostVT 1.6.17 keeps paired devices listed while Remote Access is off, makes ⌘T on a remote tab open where that tab is, and fixes Copy as One Line at word breaks. + +- Turning Remote Access off hid the Authorized Devices list, as though the devices had been forgotten. They were only kept from connecting. The list now stays while the switch is off, and a device can still be removed there. Pair New Device returns when Remote Access is back on. +- ⌘T on a tab running on another device opened a terminal on that device in its home directory. It now opens in the directory the tab is in. ⌘T puts the new tab right after the current one. ⌘N and every other way of opening a tab put it at the end, and ⌘N always starts in this device's home directory. +- Copy as One Line glued words together where a program such as Claude Code had wrapped a line between them, because the space at the break is never drawn. Rows that break between words are now joined with a space. A path or link cut at the edge of the terminal still closes up, CJK text still joins without spaces, and a trailing `\` line continuation is dropped. +- The line beside a tab's title showed the program's name, which for Claude Code is only a version number such as `2.1.304`. It now shows the last part of the tab's directory, plus the device's name for a remote tab. The Live Activity lists each session the same way: its title, then the last part of its directory. +- The recent directories in the new-tab menu show only the folder name, without the full path under it. +- Settings now says that Background Activities keeps your sessions running after you quit the app. + +Choose `iphoneos-arm64e` for roothide, `iphoneos-arm64` for a rootless bootstrap, `iGhostVT-1.6.17-macos-notarized.zip` for a Mac — it is signed and notarized, so it opens without clearing quarantine, and updates itself — or `GhostRemote-1.6.17.ipa` for an iPhone or iPad without custom firmware, which your sideloading app signs as it installs it. To run the relay, use the attached `compose.yml`; the image is `ghcr.io/owngoal-dev/ighostvt-relay`, and the digest is in `relay-image.txt`. SHA-256 sums are in `SHA256SUMS`, and the Mac app's in `SHA256SUMS.macos`. diff --git a/Makefile b/Makefile index dcb19df7..340c6936 100644 --- a/Makefile +++ b/Makefile @@ -303,6 +303,11 @@ harness: $$(find "$(ROOT_DIR)/Tests/Zmodem" -name '*.swift' | sort) \ -o "$$harness_dir/zmodem" && \ "$$harness_dir/zmodem" && \ + xcrun --sdk macosx swiftc -swift-version 5 \ + "$(ROOT_DIR)/iGhostVT/Backend/Session/OneLineText.swift" \ + $$(find "$(ROOT_DIR)/Tests/OneLineText" -name '*.swift' | sort) \ + -o "$$harness_dir/one-line" && \ + "$$harness_dir/one-line" && \ $(MAKE) --no-print-directory relay-harness # The relay end to end (Tests/RelayHarness): the Go server under Relay/ built diff --git a/Shared/Wire/IOWire.swift b/Shared/Wire/IOWire.swift index 82498395..da392446 100644 --- a/Shared/Wire/IOWire.swift +++ b/Shared/Wire/IOWire.swift @@ -35,6 +35,9 @@ enum IOWire { /// argument that names it. static let socketDescriptor: Int32 = 3 static let socketArgument = "--socket-fd" + /// Starts `ighostvtd-remote` with the switch off: it answers about the + /// paired devices and opens nothing to the network. + static let dormantArgument = "--dormant" static let executableName = "ighostvtd-io" enum Kind: UInt8 { diff --git a/Tests/OneLineText/main.swift b/Tests/OneLineText/main.swift new file mode 100644 index 00000000..ccbf71be --- /dev/null +++ b/Tests/OneLineText/main.swift @@ -0,0 +1,118 @@ +import Foundation + +// Copy as One Line (iGhostVT/Backend/Session/OneLineText.swift) against +// selections as Ghostty reads them: the first row from where the selection +// began, every other row from column 0 with its gutter, painted padding +// kept, soft-wrapped rows already one line. + +var failures: [String] = [] + +func check(_ text: String, columns: Int?, _ expected: String?, _ description: String) { + let actual = OneLineText.join(text, columns: columns) + if actual == expected { + print(" ok \(description)") + } else { + print(" FAIL \(description): \(actual.map { "\"\($0)\"" } ?? "nil") != \(expected.map { "\"\($0)\"" } ?? "nil")") + failures.append(description) + } +} + +print("one-line: rows broken between words") +check( + "Reply with only this, no \n tools: The quick fox \n river bank.", + columns: 80, + "Reply with only this, no tools: The quick fox river bank.", + "a Claude Code prompt keeps the spaces its padding stood for", +) +check( + "The quick brown fox jumps over the\n lazy dog", + columns: 40, + "The quick brown fox jumps over the lazy dog", + "a word that did not fit moved to the next row", +) +check( + "aaaa bbbb cccc ddddd\nffff gggg hhhh iiii\njjjj", + columns: 20, + "aaaa bbbb cccc ddddd ffff gggg hhhh iiii jjjj", + "a word ending exactly at the edge still gets its space", +) +check( + "aaaa bbbb cccc ddddd\nffff gggg hhhh iiii\njjjj", + columns: nil, + "aaaa bbbb cccc ddddd ffff gggg hhhh iiii jjjj", + "the same without the terminal's width", +) +check( + "a short\n line\n another", + columns: 80, + "a short line another", + "rows that end far from the edge are separate lines", +) +check("echo foo \nbar", columns: 80, "echo foo bar", "a space before the break is kept") +check("first\n\n\nsecond", columns: 80, "first second", "blank rows between paragraphs collapse to one space") +check("使用 Claude\n Code 工具", columns: 12, "使用 Claude Code 工具", "a Latin word after CJK text keeps its space") + +print("one-line: words split over rows") +check( + "⏺ Bash(cat /Users/admin/Documents/GitHub/ow\n ngoal-dev/iGhostVT/README.md)", + columns: 42, + "⏺ Bash(cat /Users/admin/Documents/GitHub/owngoal-dev/iGhostVT/README.md)", + "a path split at the edge closes up", +) +check( + "Documents/GitHub/ow\n ngoal-dev/README.md)", + columns: 42, + "Documents/GitHub/owngoal-dev/README.md)", + "a selection begun inside the path closes up", +) +check( + "ow\n ngoal-dev/README.md", + columns: 42, + "owngoal-dev/README.md", + "a first row of one word is taken for the tail of a split word", +) +check( + " See https://example.com/a/very/long/path/th\n at/keeps/going?x=1", + columns: 45, + "See https://example.com/a/very/long/path/that/keeps/going?x=1", + "a link split at the edge closes up", +) +check( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\naaaaaaa", + columns: 40, + String(repeating: "a", count: 87), + "a word over three rows closes up", +) +check("这是一段很长的中文文本被折\n行了", columns: 26, "这是一段很长的中文文本被折行了", "CJK text broken mid-sentence closes up") +check(" 这是一段很长的中文文本被\n 折行了", columns: 26, "这是一段很长的中文文本被折行了", "CJK text short of the edge still closes up") + +print("one-line: shell continuations") +check( + "docker run \\\n --rm \\\n image", + columns: 80, + "docker run --rm image", + "a trailing backslash is taken out", +) +check("a path\\\nb", columns: 80, "a path\\ b", "a backslash inside a word is left alone") + +print("one-line: not one line") +check("one row", columns: 80, nil, "a single row has nothing to join") +check(" \n ", columns: 80, nil, "blank rows have no text") + +print("one-line: widths") +let wide: [(Character, Bool)] = [("a", false), ("中", true), ("한", true), ("ア", true), ("😀", true), ("é", false), ("─", false)] +for (character, isWide) in wide { + if OneLineText.isWide(character) == isWide { + print(" ok \(character) is \(isWide ? "wide" : "narrow")") + } else { + print(" FAIL \(character) is \(isWide ? "wide" : "narrow")") + failures.append("width of \(character)") + } +} + +if failures.isEmpty { + print("one-line: all passed") +} else { + print("one-line: \(failures.count) failed") + exit(1) +} diff --git a/iGhostVT/Application/AppMenus.swift b/iGhostVT/Application/AppMenus.swift index 57150010..921e8a56 100644 --- a/iGhostVT/Application/AppMenus.swift +++ b/iGhostVT/Application/AppMenus.swift @@ -345,6 +345,10 @@ enum AppMenus { /// duplicates"). The value only has to differ from the twin's `nil`; /// `TerminalWindow` reads a `propertyList` as an `Int` tab index and /// ignores this string. + /// What a hidden alias carries, so an action can tell its alias key + /// from its listed one (⌘N from ⌘T). + static let aliasPropertyList = "alias" + private static func hidden(_ action: Selector) -> UIKeyCommand? { let shortcut = KeyShortcuts.alias(action) guard shortcut.isEnabled else { return nil } @@ -353,7 +357,7 @@ enum AppMenus { action: action, input: shortcut.input, modifierFlags: shortcut.modifiers, - propertyList: "alias", + propertyList: aliasPropertyList, attributes: .hidden, ) } diff --git a/iGhostVT/Application/TerminalWindow.swift b/iGhostVT/Application/TerminalWindow.swift index a2d934fe..5623a171 100644 --- a/iGhostVT/Application/TerminalWindow.swift +++ b/iGhostVT/Application/TerminalWindow.swift @@ -254,8 +254,14 @@ final class TerminalWindow: UIWindow, AppCommandResponder { // MARK: - File - func newTab(_: Any?) { - _ = tabManager.newTab() + /// ⌘T copies the active tab — its device, its directory — right beside + /// it. ⌘N, its hidden alias, is a fresh local shell at the end. + func newTab(_ sender: Any?) { + if (sender as? UIKeyCommand)?.propertyList as? String == AppMenus.aliasPropertyList { + _ = tabManager.newTab(.home) + } else { + _ = tabManager.newTab(besideActiveTab: true) + } } func newWindow(_: Any?) { diff --git a/iGhostVT/Backend/LiveActivity/SessionActivityController.swift b/iGhostVT/Backend/LiveActivity/SessionActivityController.swift index 1f022b5c..6c825860 100644 --- a/iGhostVT/Backend/LiveActivity/SessionActivityController.swift +++ b/iGhostVT/Backend/LiveActivity/SessionActivityController.swift @@ -126,7 +126,7 @@ final class SessionActivityController { TerminalSessionAttributes.Session( id: tab.id.uuidString, title: tab.reportedTitle, - directory: Self.displayPath(of: tab), + directory: tab.displayDirectory, shell: tab.isRemote ? "" : Self.configuredShellName, process: tab.store.processName.isEmpty ? nil : tab.store.processName, number: number, @@ -154,13 +154,6 @@ final class SessionActivityController { } } - /// Where the widget says the tab is. The daemon's reading first — it is - /// the kernel's, so it is right for a shell that reports no OSC 7 at - /// all — and the shell's own report while the session has not said yet. - private static func displayPath(of tab: TerminalTab) -> String { - tab.currentDirectory?.label ?? TerminalDirectory.abbreviate(tab.terminal.workingDirectory) - } - #if !targetEnvironment(macCatalyst) && canImport(ActivityKit) /// A quit that ended this app's shells (Background Activities off) /// takes the activity with it. Nothing else would: the process is diff --git a/iGhostVT/Backend/Session/LockableTerminalView.swift b/iGhostVT/Backend/Session/LockableTerminalView.swift index d874d094..6fc5c454 100644 --- a/iGhostVT/Backend/Session/LockableTerminalView.swift +++ b/iGhostVT/Backend/Session/LockableTerminalView.swift @@ -74,6 +74,10 @@ final class LockableTerminalView: TerminalView { /// sent while it connects goes nowhere. False when the wait is given up. var awaitConnection: (@MainActor () async -> Bool)? + /// The terminal's width in columns, nil before the surface reported + /// one. Copy as One Line reads where a row ends against it. + var terminalColumns: (@MainActor () -> Int?)? + /// What the terminal offers UIKit as its keyboard while locked. An empty /// view keeps first-responder status — and with it the hardware key /// path — while leaving nothing to raise. @@ -421,7 +425,7 @@ final class LockableTerminalView: TerminalView { override func touchSelectionMenuItems(for context: TerminalTouchSelectionMenuContext) -> [UIMenuElement] { var items = routingPaste(super.touchSelectionMenuItems(for: context)) var extras: [UIMenuElement] = [] - if let line = Self.oneLine(context.selectedText) { + if let line = OneLineText.join(context.selectedText, columns: terminalColumns?()) { extras.append(UIAction( title: String(localized: "Copy as One Line"), image: UIImage(systemName: "arrow.right.to.line"), @@ -445,21 +449,6 @@ final class LockableTerminalView: TerminalView { return items } - /// The selection with every row trimmed at both ends and the line - /// breaks between them taken out, or nil when it is one row. A - /// program that lays out its own text (Claude Code) breaks a long - /// command or path over rows with no soft-wrap mark, so the plain - /// copy keeps those breaks and a shell reads each row as a command - /// of its own. Nothing is put between the rows: a break inside a - /// path must close up, and where a space was lost the person can - /// see it before running anything. - static func oneLine(_ text: String) -> String? { - let rows = text.split(whereSeparator: \.isNewline) - guard rows.count > 1 else { return nil } - let line = rows.map { $0.trimmingCharacters(in: .whitespaces) }.joined() - return line.isEmpty ? nil : line - } - /// The selection as one http(s) link, or nil. The whitespace around /// it goes, and a link a program broke over rows — Claude Code /// prints each row as a line of its own — is put back together, diff --git a/iGhostVT/Backend/Session/OneLineText.swift b/iGhostVT/Backend/Session/OneLineText.swift new file mode 100644 index 00000000..bf2dc405 --- /dev/null +++ b/iGhostVT/Backend/Session/OneLineText.swift @@ -0,0 +1,130 @@ +import Foundation + +/// Copy as One Line: a selection over several rows put back on one, for a +/// command or a sentence a program broke over rows. +/// +/// The selection reads as Ghostty gives it: rows the terminal soft-wrapped +/// are already one line, every other row break is a newline, nothing is +/// trimmed, the first row starts where the selection began and every other +/// row at column 0. A program that lays out its own text (Claude Code, any +/// Ink program) breaks rows itself, in one of two ways: +/// +/// - at a space, between words — the space is gone from the screen, so the +/// rows join with one; +/// - inside a word longer than the row (a path, a link, CJK text with no +/// spaces), filling the row to its right edge — the rows join with +/// nothing. +/// +/// Every row's leading blanks are layout (a gutter, an indent) and go. What +/// tells the two breaks apart is where the row ends: one that stops short +/// of the edge, or ends in blanks it painted, did not split a word; one +/// that reaches it did only if the word on both sides of the break, put +/// together, is wider than a row could hold — a wrapper never splits a word +/// that fits. Two wide (CJK) characters meet with nothing between them +/// either way. A shell continuation (` \` at a row's end) is taken out. +enum OneLineText { + /// How far short of the right edge a row may end and still count as + /// reaching it: a program's text box can stop a few columns before the + /// terminal's. + static let edgeSlack = 4 + + /// The selection on one line, or nil when it is one row or has no text. + /// `columns` is the terminal's width; without it the widest row stands + /// in for it. + static func join(_ text: String, columns: Int?) -> String? { + let rows = text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + guard rows.count > 1 else { return nil } + let width = columns ?? rows.map { displayWidth(of: $0) }.max() ?? 0 + var line = "" + // The row before the next text, while that text has yet to come. + var previous: (raw: Substring, index: Int)? + for (index, raw) in rows.enumerated() { + let content = raw.drop { $0 == " " || $0 == "\t" } + guard !content.allSatisfy({ $0 == " " || $0 == "\t" }) else { continue } + if let previous { + line = trimmingTrailingBlanks(line) + // A blank row between them: a paragraph ended, no word split. + let gap = index > previous.index + 1 + ? " " + : separator(after: previous.raw, isFirst: previous.index == 0, before: raw, width: width, line: &line) + line += gap + } + line += content + previous = (raw, index) + } + line = trimmingTrailingBlanks(line) + return line.isEmpty ? nil : line + } + + /// What goes between `line` (ending with row `raw`'s text) and the next + /// row, `next`. A shell continuation is taken off `line` here. + private static func separator( + after raw: Substring, + isFirst: Bool, + before next: Substring, + width: Int, + line: inout String, + ) -> String { + let text = trimmingTrailingBlanks(raw) + if text.hasSuffix("\\"), text.dropLast().last.map({ $0 == " " || $0 == "\t" }) ?? true { + line = trimmingTrailingBlanks(line.dropLast()) + return line.isEmpty ? "" : " " + } + // Blanks painted after the text: the row stopped short of its edge. + if text.count != raw.count { return " " } + let nextContent = next.drop { $0 == " " || $0 == "\t" } + guard let last = text.last, let first = nextContent.first else { return " " } + if isWide(last), isWide(first) { return "" } + let tail = text.reversed().prefix { $0 != " " && $0 != "\t" } + let head = nextContent.prefix { $0 != " " && $0 != "\t" } + // The first row begins wherever the selection did, so where it ends + // on screen is unknown, and so is how much of its last word lies + // before the selection: a row that is all one word is taken to be + // the tail of a word too long for a row. + if isFirst { + if tail.count == text.count { return "" } + } else { + // A row the terminal soft-wrapped into this one spans several + // rows on screen; it ends where its last one does. + let end = displayWidth(of: raw) % max(width, 1) + if end != 0, end < width - edgeSlack { return " " } + } + let indent = displayWidth(of: next.prefix { $0 == " " || $0 == "\t" }) + let word = displayWidth(of: tail) + displayWidth(of: head) + return word > width - indent ? "" : " " + } + + private static func trimmingTrailingBlanks(_ text: some StringProtocol) -> String { + var end = text.endIndex + while end > text.startIndex { + let before = text.index(before: end) + guard text[before] == " " || text[before] == "\t" else { break } + end = before + } + return String(text[..) -> Int { + text.reduce(0) { $0 + (isWide($1) ? 2 : 1) } + } + + /// Whether `character` takes two cells: East Asian wide and fullwidth + /// characters, and emoji. + static func isWide(_ character: Character) -> Bool { + guard let scalar = character.unicodeScalars.first else { return false } + if character.unicodeScalars.count > 1, scalar.properties.isEmojiPresentation || character.unicodeScalars.contains("\u{FE0F}") { + return true + } + switch scalar.value { + case 0x1100 ... 0x115F, 0x2E80 ... 0x303E, 0x3041 ... 0x33FF, 0x3400 ... 0x4DBF, + 0x4E00 ... 0x9FFF, 0xA000 ... 0xA4CF, 0xAC00 ... 0xD7A3, 0xF900 ... 0xFAFF, + 0xFE30 ... 0xFE4F, 0xFF00 ... 0xFF60, 0xFFE0 ... 0xFFE6, + 0x1F300 ... 0x1F64F, 0x1F900 ... 0x1F9FF, 0x20000 ... 0x3FFFD: + return true + default: + return scalar.properties.isEmojiPresentation + } + } +} diff --git a/iGhostVT/Backend/Session/TabManager.swift b/iGhostVT/Backend/Session/TabManager.swift index ab1e701b..237c72fe 100644 --- a/iGhostVT/Backend/Session/TabManager.swift +++ b/iGhostVT/Backend/Session/TabManager.swift @@ -377,14 +377,12 @@ final class TabManager: ObservableObject { /// Puts a freshly made tab in front: placed, activated, and — when the /// scene is already up — told so, since a tab created after - /// `noteSceneActive()` gets no replay of it. A tab that copies the one - /// the user was in — ⌘T, or a menu row naming that tab's own directory - /// — goes right after it, as a browser's does, so it lands beside the - /// work it came from; one opened somewhere else (another directory, the - /// home from a tab that is not there, another device's terminal) or - /// arriving from outside (a Shortcut, a URL, a moved session) is - /// appended. The cold-launch resume batch does not go through here: it - /// appends several at once and notifies every tab, not only the new ones. + /// `noteSceneActive()` gets no replay of it. Only ⌘T puts its tab right + /// after the active one, as a browser's does, so it lands beside the + /// work it came from; every other way in — ⌘N, the new-tab button and + /// its menu, a Shortcut, a URL, a moved session — appends. The + /// cold-launch resume batch does not go through here: it appends + /// several at once and notifies every tab, not only the new ones. private func adopt(_ tab: TerminalTab, afterActiveTab: Bool = false) -> TerminalTab { withAnimation(Self.tabTransition) { if afterActiveTab, let index = tabs.firstIndex(where: { $0.id == activeTabID }) { @@ -450,21 +448,28 @@ final class TabManager: ObservableObject { /// Ghost Remote has no shell of its own: an origin on this device opens /// on the active tab's device, else the preferred paired one, and with /// no device paired it opens Settings ▸ Remote Access and no tab. + /// + /// `besideActiveTab` is ⌘T's alone (`adopt`). @discardableResult - func newTab(_ origin: Origin = .activeTab) -> TerminalTab? { + func newTab(_ origin: Origin = .activeTab, besideActiveTab: Bool = false) -> TerminalTab? { if AppEdition.isRemoteOnly, !origin.isRemote { guard let hostID = activeTab?.remoteHostID ?? RemoteTabDefaults.preferredHostID else { terminalWindow?.interface.showRemoteAccess() return nil } - let beside = if case .activeTab = origin { true } else { activeTab?.currentDirectory?.isHome == true } - return adopt(makeTab(remoteHostID: hostID), afterActiveTab: beside) + let tab = if case .activeTab = origin, let activeTab, activeTab.remoteHostID == hostID { + makeTab(besideRemote: activeTab, onHostID: hostID) + } else { + makeTab(remoteHostID: hostID) + } + return adopt(tab, afterActiveTab: besideActiveTab) } let tab = switch origin { case .activeTab: - // From a remote tab, ⌘T opens another shell on that device. - if let hostID = activeTab?.remoteHostID { - makeTab(remoteHostID: hostID) + // From a remote tab, ⌘T opens another shell on that device, + // where that tab's shell is. + if let activeTab, let hostID = activeTab.remoteHostID { + makeTab(besideRemote: activeTab, onHostID: hostID) } else { makeTab(inheritDirectoryFrom: activeTab?.daemonSessionID) } @@ -477,31 +482,20 @@ final class TabManager: ObservableObject { case let .remote(hostID, directory): makeTab(startDirectory: directory?.path, remoteHostID: hostID) } - return adopt(tab, afterActiveTab: opensBesideActiveTab(origin)) + return adopt(tab, afterActiveTab: besideActiveTab) } - /// Whether `origin` is the active tab again — the same device and the - /// same directory — so the new tab belongs beside it. A directory the - /// active tab has not reported yet matches nothing but ⌘T. - private func opensBesideActiveTab(_ origin: Origin) -> Bool { - guard let activeTab else { return false } - let here = activeTab.currentDirectory - switch origin { - case .activeTab: - return true - case .home: - return !activeTab.isRemote && here?.isHome == true - case let .session(sessionID): - return !activeTab.isRemote && sessionID == activeTab.daemonSessionID - case let .directory(directory): - return !activeTab.isRemote && here?.path == directory.path - case let .remote(hostID, directory): - guard activeTab.remoteHostID == hostID else { return false } - if let directory { - return here?.path == directory.path - } - return here?.isHome == true - } + /// ⌘T from a remote tab: that device's daemon reads the directory from + /// its own session, as a local ⌘T does — the helper hands + /// `openSession` over whole. The directory the tab last reported goes + /// too, for a session the host no longer has (it ended, or the tab is + /// still reconnecting); the live session wins when both are there. + private func makeTab(besideRemote tab: TerminalTab, onHostID hostID: String) -> TerminalTab { + makeTab( + inheritDirectoryFrom: tab.remoteSessionID, + startDirectory: tab.currentDirectory?.path, + remoteHostID: hostID, + ) } /// Closing the last tab leaves the window empty on purpose: the empty diff --git a/iGhostVT/Backend/Session/TerminalDirectory.swift b/iGhostVT/Backend/Session/TerminalDirectory.swift index 9fc9b81c..7aae5c58 100644 --- a/iGhostVT/Backend/Session/TerminalDirectory.swift +++ b/iGhostVT/Backend/Session/TerminalDirectory.swift @@ -49,12 +49,6 @@ struct TerminalDirectory: Hashable, Codable, Sendable { return String(trimmed[trimmed.index(after: slash)...]) } - /// What goes under `name`: the whole spelling, unless it says nothing - /// more (`/`, `@jb`). - var nameSubtitle: String? { - name == label ? nil : label - } - /// Whether this is the session user's home. The new-tab menu's first /// row already opens there, so no other row should offer it again, and /// the daemon spelling it `~` is the whole test. diff --git a/iGhostVT/Backend/Session/TerminalSessionStore.swift b/iGhostVT/Backend/Session/TerminalSessionStore.swift index bbaddf08..1986dfcc 100644 --- a/iGhostVT/Backend/Session/TerminalSessionStore.swift +++ b/iGhostVT/Backend/Session/TerminalSessionStore.swift @@ -605,6 +605,11 @@ final class TerminalSessionStore: ObservableObject { return paths } + /// The terminal's width as the surface last reported it. + var viewportColumns: Int? { + relay.columns + } + /// Returns once the tab is connected: true then, false if the waiting /// task is cancelled or the connection gives up (failed, or the session /// is in use elsewhere). Reconnects in between are waited out. @@ -696,7 +701,6 @@ final class TerminalSessionStore: ObservableObject { AppLog.verbose(.session, "received \(chunks) chunk(s), \(bytes) bytes (#\(notedChunks)) status=\(status)") } noteOutput() - notePageChanged() } private func handle(_ event: TerminalTransportEvent) { @@ -872,31 +876,6 @@ final class TerminalSessionStore: ObservableObject { isAwaitingFirstOutput = false } - /// Bumped as output arrives, at most once a second with one trailing - /// bump after a burst, so a row whose second line mirrors the page - /// (`TerminalTab.secondaryTitle`) re-renders while the page changes - /// without a redraw per chunk. - @Published private(set) var pageGeneration: UInt64 = 0 - private var lastPageBump = Date.distantPast - private var pageBumpScheduled = false - - private func notePageChanged() { - let now = Date() - if now.timeIntervalSince(lastPageBump) >= 1 { - lastPageBump = now - pageGeneration &+= 1 - } else if !pageBumpScheduled { - pageBumpScheduled = true - Task { [weak self] in - try? await Task.sleep(nanoseconds: 1_000_000_000) - guard let self else { return } - pageBumpScheduled = false - lastPageBump = Date() - pageGeneration &+= 1 - } - } - } - private func noteOutput() { guard !hasReceivedOutput else { return } hasReceivedOutput = true @@ -910,10 +889,6 @@ final class TerminalSessionStore: ObservableObject { /// every pair. The carriage return alone still guarantees column zero. private func printStatusLine(_ message: String) { noteOutput() - // The page changed even though no transport event fired: a row whose - // second line mirrors the page would otherwise sit on the state from - // before this line until the next real output. - notePageChanged() session.receive("\r\u{1b}[2m[iGhostVT] \(message)\u{1b}[0m\r\n") } } @@ -951,6 +926,13 @@ private final class TransportRelay: @unchecked Sendable { return _latestViewport != nil } + /// The columns of the surface's latest grid, nil before its first report. + var columns: Int? { + lock.lock() + defer { lock.unlock() } + return _latestViewport?.columns + } + var transport: TerminalTransport? { get { lock.lock() diff --git a/iGhostVT/Backend/Session/TerminalTab.swift b/iGhostVT/Backend/Session/TerminalTab.swift index 18836a19..a1cdcd57 100644 --- a/iGhostVT/Backend/Session/TerminalTab.swift +++ b/iGhostVT/Backend/Session/TerminalTab.swift @@ -115,6 +115,14 @@ final class TerminalTab: ObservableObject, Identifiable { store.currentDirectory } + /// Where the tab is, as the Live Activity and the tab's second line + /// say it. The daemon's reading first — it is the kernel's, so it is + /// right for a shell that reports no OSC 7 at all — and the shell's own + /// report while the session has not said yet. Empty when neither has. + var displayDirectory: String { + currentDirectory?.label ?? TerminalDirectory.abbreviate(terminal.workingDirectory) + } + /// `inheritDirectoryFrom` is the daemon session of the tab this one was /// opened from; a resumed tab ignores it, since its shell already sits /// somewhere. The transport factory sends it on every open, so a shell @@ -251,6 +259,8 @@ final class TerminalTab: ObservableObject, Identifiable { store.$inferredTitle.removeDuplicates().map { _ in () }, store.$processName.removeDuplicates().map { _ in () }, ) + .merge(with: terminal.$workingDirectory.removeDuplicates().map { _ in () }) + .merge(with: store.$currentDirectory.removeDuplicates().map { _ in () }) .throttle(for: Self.retitleInterval, scheduler: DispatchQueue.main, latest: true) .sink { [weak self] in self?.publishRetitle() @@ -317,6 +327,9 @@ final class TerminalTab: ObservableObject, Identifiable { view.awaitConnection = { [weak self] in await self?.store.waitUntilConnected() ?? false } + view.terminalColumns = { [weak self] in + self?.store.viewportColumns + } view.isInteractionLocked = self?.isLocked ?? false view.isSoftwareKeyboardLocked = self?.isKeyboardLocked ?? false view.onLockedTouch = { [weak self] in @@ -430,20 +443,17 @@ final class TerminalTab: ObservableObject, Identifiable { return store.processName.isEmpty ? store.endpointDescription : store.processName } - /// The line under the title: the foreground process's name ("zsh", - /// "vim", "grok"), which the daemon reports and which stays short and - /// stable while programs retitle at will. Never repeats `displayTitle`: - /// when the process name is already the first line (or there is none), - /// this is the page's own last non-empty row, so an untitled session - /// still reads as what it is doing rather than as "zsh" twice. + /// The line beside the title: the last component of the directory + /// the tab is in, as the Live Activity lists it — never the + /// foreground program's name, which for some programs is only a + /// version number. A shell on another device says which one. var secondaryTitle: String { - let process = store.processName - let line = process.isEmpty || process == displayTitle ? pageFallbackLine : process - // A shell on another device says which one, wherever the tab shows. + let directory = displayDirectory + let name = directory.isEmpty ? "" : TerminalDirectory(path: directory).name if let remoteHostName { - return line.isEmpty ? remoteHostName : "\(line) · \(remoteHostName)" + return name.isEmpty ? remoteHostName : "\(name) · \(remoteHostName)" } - return line + return name } /// The paired device's name, for a remote tab. @@ -451,21 +461,6 @@ final class TerminalTab: ObservableObject, Identifiable { remoteHostID.flatMap { PairedRemoteHostStore.host(id: $0)?.displayName } } - /// The last non-empty row of the viewport, and the endpoint only while - /// the page is still blank. Computed on render; `store.pageGeneration` - /// is what re-renders the rows while output flows, so this follows the - /// page at that pace rather than per chunk. - private var pageFallbackLine: String { - _ = store.pageGeneration - for line in snapshotPreview().split(separator: "\n", omittingEmptySubsequences: false).reversed() { - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - return trimmed - } - } - return store.endpointDescription - } - /// The reported title without the endpoint fallback, for the places /// that have a better name of their own for a session that never /// reported one — the Live Activity, whose widget labels it with the diff --git a/iGhostVT/Interface/Settings/Mac/MacSettingsPanes.swift b/iGhostVT/Interface/Settings/Mac/MacSettingsPanes.swift index 3f3e47f7..a26cd33a 100644 --- a/iGhostVT/Interface/Settings/Mac/MacSettingsPanes.swift +++ b/iGhostVT/Interface/Settings/Mac/MacSettingsPanes.swift @@ -109,16 +109,17 @@ import SwiftUI if heldByRemoteAccess { MacSettingsNote( """ - Background Activities keeps your terminals running. It \ - stays on while Remote Access is on, so paired devices \ - can reach them. + Background Activities keeps your sessions running after \ + you quit the app. It stays on while Remote Access is on, \ + so paired devices can reach them. """, ) } else { MacSettingsNote( """ - Background Activities keeps your terminals running. With it \ - off, shells and the tasks they run end when the app quits. + Background Activities keeps your sessions running after \ + you quit the app. With it off, shells and the tasks they \ + run end when the app quits. """, ) } diff --git a/iGhostVT/Interface/Settings/Remote/RemoteAccessModel.swift b/iGhostVT/Interface/Settings/Remote/RemoteAccessModel.swift index 83121333..146f6a55 100644 --- a/iGhostVT/Interface/Settings/Remote/RemoteAccessModel.swift +++ b/iGhostVT/Interface/Settings/Remote/RemoteAccessModel.swift @@ -88,6 +88,13 @@ final class RemoteAccessModel: ObservableObject { private func apply(_ status: RemoteAccessStatus) { hasLoaded = true + var status = status + // The daemon answers for a helper that is not up yet (no host id) + // without the devices; they are still paired, so the list stays + // until the helper says otherwise. + if status.hostID == nil, !status.isUnavailable { + status.devices = self.status.devices + } if status.isEnabled, status.state == .listening, UserDefaults.standard.bool(forKey: Self.namePendingKey) { sendName() } diff --git a/iGhostVT/Interface/Settings/Remote/RemoteAccessView.swift b/iGhostVT/Interface/Settings/Remote/RemoteAccessView.swift index c021dc7a..31ceb839 100644 --- a/iGhostVT/Interface/Settings/Remote/RemoteAccessView.swift +++ b/iGhostVT/Interface/Settings/Remote/RemoteAccessView.swift @@ -20,8 +20,9 @@ struct RemoteAccessView: View { Form { thisDeviceSection // Ghost Remote is never a host: it has no daemon to open - // terminals in. - if !AppEdition.isRemoteOnly, model.isEnabled { + // terminals in. Switched off, the devices paired so far are + // still listed — off stops them connecting, it forgets nobody. + if !AppEdition.isRemoteOnly, model.isEnabled || !model.status.devices.isEmpty { allowedDevicesSection } // Only once a .vtrpsc file has been opened: no relay, no section. @@ -170,13 +171,15 @@ struct RemoteAccessView: View { model.revoke(model.status.devices[index]) } } - Button("Pair New Device…") { - Task { - await model.beginPairing() - isShowingPairingCode = true + if model.isEnabled { + Button("Pair New Device…") { + Task { + await model.beginPairing() + isShowingPairingCode = true + } } + .disabled(model.status.state != .listening) } - .disabled(model.status.state != .listening) } header: { Text("Authorized Devices") .font(DS.Font.caption) diff --git a/iGhostVT/Interface/Settings/Sections/SessionSettingsSection.swift b/iGhostVT/Interface/Settings/Sections/SessionSettingsSection.swift index 56114795..ede5595a 100644 --- a/iGhostVT/Interface/Settings/Sections/SessionSettingsSection.swift +++ b/iGhostVT/Interface/Settings/Sections/SessionSettingsSection.swift @@ -38,10 +38,10 @@ struct SessionSettingsSection: View { } footer: { if !AppEdition.isRemoteOnly { if heldByRemoteAccess { - Text("Background Activities keeps your terminals running. It stays on while Remote Access is on, so paired devices can reach them.") + Text("Background Activities keeps your sessions running after you quit the app. It stays on while Remote Access is on, so paired devices can reach them.") .font(DS.Font.detail) } else { - Text("Background Activities keeps your terminals running. With it off, shells and the tasks they run end when the app quits.") + Text("Background Activities keeps your sessions running after you quit the app. With it off, shells and the tasks they run end when the app quits.") .font(DS.Font.detail) } } diff --git a/iGhostVT/Interface/Support/NewTabMenu.swift b/iGhostVT/Interface/Support/NewTabMenu.swift index e8dd58b4..9ebb73d5 100644 --- a/iGhostVT/Interface/Support/NewTabMenu.swift +++ b/iGhostVT/Interface/Support/NewTabMenu.swift @@ -187,7 +187,7 @@ struct NewTabMenuContent: View { if !choices.recents.isEmpty { Section { ForEach(choices.recents, id: \.path) { directory in - row(directory.name, subtitle: directory.nameSubtitle, systemImage: "clock", origin: .directory(directory)) + row(directory.name, systemImage: "clock", origin: .directory(directory)) } } header: { Text("Recent") @@ -286,7 +286,6 @@ struct NewTabMenuContent: View { ForEach(recents, id: \.path) { directory in row( directory.name, - subtitle: directory.nameSubtitle, systemImage: "clock", origin: .remote(hostID: host.id, directory: directory), ) @@ -316,11 +315,9 @@ struct NewTabMenuContent: View { } /// A path is not copy: `title` is a string the daemon reported, so it - /// takes the plain-string label rather than a localizable key. A - /// second line of text in a menu row is its subtitle. + /// takes the plain-string label rather than a localizable key. private func row( _ title: String, - subtitle: String? = nil, systemImage: String, origin: TabManager.Origin, ) -> some View { @@ -330,9 +327,6 @@ struct NewTabMenuContent: View { } label: { SwiftUI.Label { Text(verbatim: title) - if let subtitle { - Text(verbatim: subtitle) - } } icon: { Image(systemName: systemImage) } diff --git a/iGhostVT/Interface/Support/NewTabMenuElements.swift b/iGhostVT/Interface/Support/NewTabMenuElements.swift index 4100de00..ea6f8a34 100644 --- a/iGhostVT/Interface/Support/NewTabMenuElements.swift +++ b/iGhostVT/Interface/Support/NewTabMenuElements.swift @@ -93,16 +93,9 @@ enum NewTabMenuElements { } } - /// A recent directory's row: its folder's name, the whole spelling - /// under it. iOS 15 has no subtitle, so there the title is the whole - /// spelling, as it always was. + /// A recent directory's row: its folder's name alone. private static func recentRow(_ directory: TerminalDirectory, _ action: UIAction) -> UIAction { - if #available(iOS 16.0, *) { - action.title = directory.name - action.subtitle = directory.nameSubtitle - } else { - action.title = directory.label - } + action.title = directory.name return action } diff --git a/iGhostVT/Interface/Support/TabLabels.swift b/iGhostVT/Interface/Support/TabLabels.swift index c374722e..34e69355 100644 --- a/iGhostVT/Interface/Support/TabLabels.swift +++ b/iGhostVT/Interface/Support/TabLabels.swift @@ -34,8 +34,8 @@ struct ObservedTabTitle: View { } } -/// The dim line beside the title: what the session reports about itself -/// while the title itself stays the stable process name. +/// The dim line beside the title: the directory's last component +/// (`TerminalTab.secondaryTitle`). struct ObservedTabSubtitle: View { @ObservedObject var tab: TerminalTab diff --git a/iGhostVT/Resources/Localizable.xcstrings b/iGhostVT/Resources/Localizable.xcstrings index 7295b440..52fba44d 100644 --- a/iGhostVT/Resources/Localizable.xcstrings +++ b/iGhostVT/Resources/Localizable.xcstrings @@ -4287,168 +4287,168 @@ } } }, - "Background Activities keeps your terminals running. It stays on while Remote Access is on, so paired devices can reach them." : { + "Background Activities keeps your sessions running after you quit the app. It stays on while Remote Access is on, so paired devices can reach them." : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "تُبقي الأنشطة في الخلفية الطرفيات قيد التشغيل. وتبقى مفعّلة ما دام الوصول عن بُعد مفعّلًا، حتى تتمكن الأجهزة المقترنة من الوصول إليها." + "value" : "تُبقي الأنشطة في الخلفية جلساتك قيد التشغيل بعد إنهاء التطبيق. وتبقى مفعّلة ما دام الوصول عن بُعد مفعّلًا، حتى تتمكن الأجهزة المقترنة من الوصول إليها." } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Hintergrundaktivitäten halten deine Terminals am Laufen. Sie bleiben an, solange der Fernzugriff an ist, damit gekoppelte Geräte sie erreichen." + "value" : "Hintergrundaktivitäten halten deine Sitzungen am Laufen, auch nachdem du die App beendet hast. Sie bleiben an, solange der Fernzugriff an ist, damit gekoppelte Geräte sie erreichen." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Background Activities keeps your terminals running. It stays on while Remote Access is on, so paired devices can reach them." + "value" : "Background Activities keeps your sessions running after you quit the app. It stays on while Remote Access is on, so paired devices can reach them." } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "La actividad en segundo plano mantiene tus terminales en ejecución. Sigue activada mientras el acceso remoto lo esté, para que los dispositivos enlazados puedan acceder a ellos." + "value" : "La actividad en segundo plano mantiene tus sesiones en ejecución después de salir de la app. Sigue activada mientras el acceso remoto lo esté, para que los dispositivos enlazados puedan acceder a ellos." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Les activités en arrière-plan maintiennent vos terminaux en marche. Elles restent activées tant que l’accès à distance l’est, pour que les appareils jumelés puissent les atteindre." + "value" : "Les activités en arrière-plan maintiennent vos sessions actives après la fermeture de l’app. Elles restent activées tant que l’accès à distance l’est, pour que les appareils jumelés puissent les atteindre." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "L’attività in background mantiene attivi i terminali. Resta attiva finché lo è l’accesso remoto, così i dispositivi abbinati possono raggiungerli." + "value" : "L’attività in background mantiene attive le sessioni anche dopo che esci dall’app. Resta attiva finché lo è l’accesso remoto, così i dispositivi abbinati possono raggiungerli." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "バックグラウンドアクティビティを有効にすると、ターミナルは実行され続けます。リモートアクセスがオンの間はオンのままになり、ペアリングしたデバイスからアクセスできます。" + "value" : "バックグラウンドアクティビティを有効にすると、アプリを終了してもセッションは実行され続けます。リモートアクセスがオンの間はオンのままになり、ペアリングしたデバイスからアクセスできます。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "백그라운드 활동을 켜면 터미널이 계속 실행됩니다. 원격 접근이 켜져 있는 동안에는 켜진 상태로 유지되어 페어링된 기기가 터미널에 접근할 수 있습니다." + "value" : "백그라운드 활동을 켜면 앱을 종료한 후에도 세션이 계속 실행됩니다. 원격 접근이 켜져 있는 동안에는 켜진 상태로 유지되어 페어링된 기기가 터미널에 접근할 수 있습니다." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "As atividades em segundo plano mantêm seus terminais em execução. Ficam ativadas enquanto o acesso remoto estiver ativado, para que os dispositivos pareados possam acessá-los." + "value" : "As atividades em segundo plano mantêm suas sessões em execução depois que você sai do app. Ficam ativadas enquanto o acesso remoto estiver ativado, para que os dispositivos pareados possam acessá-los." } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Фоновая активность сохраняет терминалы запущенными. Пока включён удалённый доступ, она остаётся включённой, чтобы сопряжённые устройства могли к ним подключиться." + "value" : "Фоновая активность сохраняет сеансы запущенными после выхода из приложения. Пока включён удалённый доступ, она остаётся включённой, чтобы сопряжённые устройства могли к ним подключиться." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Hoạt động nền giữ cho các terminal tiếp tục chạy. Tùy chọn này luôn bật khi Truy cập từ xa đang bật, để các thiết bị đã ghép đôi có thể truy cập chúng." + "value" : "Hoạt động nền giữ cho các phiên tiếp tục chạy sau khi bạn thoát ứng dụng. Tùy chọn này luôn bật khi Truy cập từ xa đang bật, để các thiết bị đã ghép đôi có thể truy cập chúng." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "启用后台活动将会保持你的终端运行。远程访问开启期间它会保持开启,以便已配对的设备访问你的终端。" + "value" : "启用后台活动后,退出软件时会话仍会保持运行。远程访问开启期间它会保持开启,以便已配对的设备访问你的终端。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "啟用背景活動將會保持你的終端機執行。遠端存取開啟期間它會保持開啟,以便已配對的裝置存取你的終端機。" + "value" : "啟用背景活動後,結束軟體時工作階段仍會保持執行。遠端存取開啟期間它會保持開啟,以便已配對的裝置存取你的終端機。" } } } }, - "Background Activities keeps your terminals running. With it off, shells and the tasks they run end when the app quits." : { + "Background Activities keeps your sessions running after you quit the app. With it off, shells and the tasks they run end when the app quits." : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "تُبقي الأنشطة في الخلفية الطرفيات قيد التشغيل. عند إيقافها، تنتهي الأصداف والمهام التي تشغّلها عند إنهاء التطبيق." + "value" : "تُبقي الأنشطة في الخلفية جلساتك قيد التشغيل بعد إنهاء التطبيق. عند إيقافها، تنتهي الأصداف والمهام التي تشغّلها عند إنهاء التطبيق." } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Hintergrundaktivitäten halten deine Terminals am Laufen. Ist sie aus, enden Shells und ihre laufenden Aufgaben beim Beenden der App." + "value" : "Hintergrundaktivitäten halten deine Sitzungen am Laufen, auch nachdem du die App beendet hast. Ist sie aus, enden Shells und ihre laufenden Aufgaben beim Beenden der App." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Background Activities keeps your terminals running. With it off, shells and the tasks they run end when the app quits." + "value" : "Background Activities keeps your sessions running after you quit the app. With it off, shells and the tasks they run end when the app quits." } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "La actividad en segundo plano mantiene tus terminales en ejecución. Si la desactivas, los shells y sus tareas terminan al salir de la app." + "value" : "La actividad en segundo plano mantiene tus sesiones en ejecución después de salir de la app. Si la desactivas, los shells y sus tareas terminan al salir de la app." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Les activités en arrière-plan maintiennent vos terminaux en marche. Désactivées, les shells et leurs tâches s’arrêtent quand l’app se ferme." + "value" : "Les activités en arrière-plan maintiennent vos sessions actives après la fermeture de l’app. Désactivées, les shells et leurs tâches s’arrêtent quand l’app se ferme." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "L’attività in background mantiene attivi i terminali. Se è disattivata, le shell e le attività in esecuzione terminano quando esci dall’app." + "value" : "L’attività in background mantiene attive le sessioni anche dopo che esci dall’app. Se è disattivata, le shell e le attività in esecuzione terminano quando esci dall’app." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "バックグラウンドアクティビティを有効にすると、ターミナルは実行され続けます。オフにすると、アプリの終了時にシェルと実行中のタスクが終了します。" + "value" : "バックグラウンドアクティビティを有効にすると、アプリを終了してもセッションは実行され続けます。オフにすると、アプリの終了時にシェルと実行中のタスクが終了します。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "백그라운드 활동을 켜면 터미널이 계속 실행됩니다. 끄면 앱을 종료할 때 셸과 실행 중인 작업이 종료됩니다." + "value" : "백그라운드 활동을 켜면 앱을 종료한 후에도 세션이 계속 실행됩니다. 끄면 앱을 종료할 때 셸과 실행 중인 작업이 종료됩니다." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "As atividades em segundo plano mantêm seus terminais em execução. Desativadas, os shells e suas tarefas terminam quando o app é encerrado." + "value" : "As atividades em segundo plano mantêm suas sessões em execução depois que você sai do app. Desativadas, os shells e suas tarefas terminam quando o app é encerrado." } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Фоновая активность сохраняет терминалы запущенными. Если она выключена, оболочки и выполняемые задачи завершаются при выходе из приложения." + "value" : "Фоновая активность сохраняет сеансы запущенными после выхода из приложения. Если она выключена, оболочки и выполняемые задачи завершаются при выходе из приложения." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Hoạt động nền giữ cho các terminal tiếp tục chạy. Khi tắt, shell và các tác vụ đang chạy sẽ kết thúc khi thoát ứng dụng." + "value" : "Hoạt động nền giữ cho các phiên tiếp tục chạy sau khi bạn thoát ứng dụng. Khi tắt, shell và các tác vụ đang chạy sẽ kết thúc khi thoát ứng dụng." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "启用后台活动将会保持你的终端运行。如果关闭,shell 和正在执行的任务会在退出时结束。" + "value" : "启用后台活动后,退出软件时会话仍会保持运行。如果关闭,shell 和正在执行的任务会在退出时结束。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "啟用背景活動將會保持你的終端機執行。如果關閉,shell 和正在執行的工作會在結束時終止。" + "value" : "啟用背景活動後,結束軟體時工作階段仍會保持執行。如果關閉,shell 和正在執行的工作會在結束時終止。" } } } diff --git a/iGhostVTDaemon/Server/RemoteSupervisor.swift b/iGhostVTDaemon/Server/RemoteSupervisor.swift index e6ea3303..cbca8c22 100644 --- a/iGhostVTDaemon/Server/RemoteSupervisor.swift +++ b/iGhostVTDaemon/Server/RemoteSupervisor.swift @@ -2,7 +2,10 @@ import Darwin import Dispatch import XPC -/// Owns `ighostvtd-remote`, the remote-access helper, while the switch is on. +/// Owns `ighostvtd-remote`, the remote-access helper, while the switch is on +/// — and a dormant one while it is off, started when the app asks about the +/// paired devices, so they stay listed and removable without anything +/// listening. /// /// The switch is a file (`flagPath`): present means on. The proxy reads it at launch — `RunAtLoad` is what brings the /// helper up after a boot or a login — and `setRemoteAccess` writes or @@ -30,6 +33,8 @@ final class RemoteSupervisor { private var channel: IOChannel? private var childPID: pid_t = 0 + /// The child runs with the switch off (`IOWire.dormantArgument`). + private var childIsDormant = false private var exitSource: DispatchSourceProcess? private var lastSpawn = DispatchTime(uptimeNanoseconds: 0) private var respawnScheduled = false @@ -99,10 +104,12 @@ final class RemoteSupervisor { let operation = iGhostVTOperation(rawValue: xpc_dictionary_get_uint64(message, iGhostVTWireKey.operation)) if operation == .setRemoteAccess { let enabled = xpc_dictionary_get_bool(message, iGhostVTWireKey.enabled) + let wasDormant = childIsDormant && channel != nil setEnabled(enabled) // Off is answered here: the helper is on its way out, and it - // would still say it is on. - guard enabled else { return completion(Self.offlineStatus(enabled: false)) } + // would still say it is on. So is on over a dormant helper, + // which is on its way out too and would still say it is off. + guard enabled, !wasDormant else { return completion(Self.offlineStatus(enabled: enabled)) } // What the switch now says, from the helper when it is up. let status = xpc_dictionary_create(nil, nil, 0) xpc_dictionary_set_uint64(status, iGhostVTWireKey.version, iGhostVTProtocol.version) @@ -113,6 +120,11 @@ final class RemoteSupervisor { } private func forwardOrCompose(_ message: xpc_object_t, completion: @escaping (xpc_object_t) -> Void) { + // With the switch off the paired devices are still the helper's to + // tell, so it is started for the asking, dormant. + if channel == nil, childPID == 0, !isEnabled { + try? spawn(dormant: true) + } guard let channel else { completion(Self.offlineStatus(enabled: isEnabled)) return @@ -152,9 +164,13 @@ final class RemoteSupervisor { } } DaemonFileLog.log("remote access turned on") - if channel == nil { + if channel == nil, childPID == 0 { spawnOrRetry() + } else if childIsDormant { + // Its exit brings the real one up (`handleLinkClosed`). + stop() } + // A child still being reaped is respawned by `reap`. } else { if let directory { unlinkat(directory, Self.flagName, 0) @@ -179,7 +195,7 @@ final class RemoteSupervisor { } } - private func spawn() throws { + private func spawn(dormant: Bool = false) throws { lastSpawn = .now() var pair: [Int32] = [-1, -1] guard socketpair(AF_UNIX, SOCK_STREAM, 0, &pair) == 0 else { @@ -201,7 +217,10 @@ final class RemoteSupervisor { defer { posix_spawnattr_destroy(&attributes) } posix_spawnattr_setflags(&attributes, Self.closeOnExecByDefault) - let arguments = [executablePath, IOWire.socketArgument, String(IOWire.socketDescriptor)] + var arguments = [executablePath, IOWire.socketArgument, String(IOWire.socketDescriptor)] + if dormant { + arguments.append(IOWire.dormantArgument) + } var argv: [UnsafeMutablePointer?] = arguments.map { strdup($0) } argv.append(nil) defer { argv.forEach { free($0) } } @@ -215,6 +234,7 @@ final class RemoteSupervisor { throw iGhostVTDaemonError.transportFailure } childPID = pid + childIsDormant = dormant let channel = IOChannel(descriptor: parentEnd, queue: queue) channel.onFrame = { [weak self] header, payload in self?.handleFrame(header, payload: payload) @@ -231,7 +251,7 @@ final class RemoteSupervisor { } self.exitSource = exitSource exitSource.activate() - DaemonFileLog.log("remote helper spawned as pid \(pid)") + DaemonFileLog.log("remote helper spawned as pid \(pid)\(dormant ? ", dormant" : "")") } private func handleFrame(_ header: IOWire.Header, payload: UnsafeRawBufferPointer) { diff --git a/iGhostVTRemote/RemoteService.swift b/iGhostVTRemote/RemoteService.swift index 04cb4fd8..e14f8fc9 100644 --- a/iGhostVTRemote/RemoteService.swift +++ b/iGhostVTRemote/RemoteService.swift @@ -15,6 +15,12 @@ final class RemoteService: RelayLinkHost { autoreleaseFrequency: .workItem, ) private let management: IOChannel + /// Spawned while the switch is off, only so the app can list and remove + /// the paired devices: no listener, no advertisement, no relay, and gone + /// once nobody has asked anything for `dormantIdleSeconds`. + private let isDormant: Bool + private static let dormantIdleSeconds = 60 + private var dormantIdleGeneration = 0 private(set) var store = RemoteStore.load() private let deviceName = RemoteHostName.current() @@ -73,8 +79,9 @@ final class RemoteService: RelayLinkHost { private var pairing: PairingWindow? - init(managementDescriptor: Int32) { + init(managementDescriptor: Int32, isDormant: Bool = false) { management = IOChannel(descriptor: managementDescriptor, queue: queue) + self.isDormant = isDormant } func start() { @@ -93,6 +100,12 @@ final class RemoteService: RelayLinkHost { self?.forwardLog(line) } } + guard !isDormant else { + RemoteLog.log("dormant as uid \(getuid()), \(store.devices.count) paired device(s)") + state = .off + extendDormancy() + return + } RemoteLog.log("starting as uid \(getuid()), host \(store.hostID), \(store.devices.count) paired device(s)") connectAnchor() startListener() @@ -115,6 +128,18 @@ final class RemoteService: RelayLinkHost { pathMonitor.start(queue: queue) } + /// A dormant helper lives as long as it is asked things: the settings + /// page polls every few seconds while it is open. + private func extendDormancy() { + dormantIdleGeneration += 1 + let generation = dormantIdleGeneration + queue.asyncAfter(deadline: .now() + .seconds(Self.dormantIdleSeconds)) { [weak self] in + guard let self, generation == dormantIdleGeneration else { return } + RemoteLog.log("dormant and idle, exiting") + exit(EXIT_SUCCESS) + } + } + // MARK: - The daemon /// One connection to the daemon held for as long as this process runs: @@ -535,6 +560,38 @@ final class RemoteService: RelayLinkHost { private func handleManagement(_ header: IOWire.Header, payload: UnsafeRawBufferPointer) { guard header.kind == .request, let message = IOCodec.decode(payload) else { return } let operation = iGhostVTOperation(rawValue: xpc_dictionary_get_uint64(message, iGhostVTWireKey.operation)) + if isDormant { + extendDormancy() + } + let code = isDormant ? handleDormant(operation, message) : handle(operation, message) + guard header.tag != 0 else { return } + let reply = statusReply() + xpc_dictionary_set_int64(reply, iGhostVTWireKey.code, code.rawValue) + _ = management.send(.reply, peer: header.peer, tag: header.tag, object: reply) + } + + /// While the switch is off the devices can be seen, renamed for, and + /// removed — nothing that opens anything to the network. + private func handleDormant(_ operation: iGhostVTOperation?, _ message: xpc_object_t) -> iGhostVTReplyCode { + switch operation { + case .remoteStatus: + return .success + case .revokeRemoteDevice: + let deviceID = xpc_dictionary_get_string(message, iGhostVTWireKey.deviceID).map { String(cString: $0) } + guard let deviceID, store.remove(deviceID: deviceID) else { return .invalidRequest } + RemoteLog.log("revoked device \(deviceID)") + return .success + case .setHostName: + let requested = xpc_dictionary_get_string(message, iGhostVTWireKey.hostName) + .map { String(cString: $0).trimmingCharacters(in: .whitespacesAndNewlines) } ?? "" + store.setHostName(requested.isEmpty ? nil : RemoteAccess.sanitizedName(requested)) + return .success + default: + return .invalidRequest + } + } + + private func handle(_ operation: iGhostVTOperation?, _ message: xpc_object_t) -> iGhostVTReplyCode { var code = iGhostVTReplyCode.success switch operation { case .remoteStatus: @@ -594,17 +651,14 @@ final class RemoteService: RelayLinkHost { default: code = .invalidRequest } - guard header.tag != 0 else { return } - let reply = statusReply() - xpc_dictionary_set_int64(reply, iGhostVTWireKey.code, code.rawValue) - _ = management.send(.reply, peer: header.peer, tag: header.tag, object: reply) + return code } private func statusReply() -> xpc_object_t { expirePairingIfNeeded() let reply = xpc_dictionary_create(nil, nil, 0) xpc_dictionary_set_uint64(reply, iGhostVTWireKey.version, iGhostVTProtocol.version) - xpc_dictionary_set_bool(reply, iGhostVTWireKey.enabled, true) + xpc_dictionary_set_bool(reply, iGhostVTWireKey.enabled, !isDormant) xpc_dictionary_set_string(reply, iGhostVTWireKey.remoteState, state.rawValue) if let failureMessage { xpc_dictionary_set_string(reply, iGhostVTWireKey.errorMessage, failureMessage) @@ -612,8 +666,12 @@ final class RemoteService: RelayLinkHost { xpc_dictionary_set_string(reply, iGhostVTWireKey.hostID, store.hostID) xpc_dictionary_set_string(reply, iGhostVTWireKey.hostName, hostName) xpc_dictionary_set_string(reply, iGhostVTWireKey.appVersion, RemoteAccess.appVersion) - let relayConfiguration = store.relay.flatMap { try? RelayConfiguration(data: $0) } - xpc_dictionary_set_string(reply, iGhostVTWireKey.relayFingerprint, relayConfiguration?.fingerprint ?? "") + // Dormant, the relay is not the helper's to report: no fingerprint + // is the app's sign that nothing runs to hand one to. + let relayConfiguration = isDormant ? nil : store.relay.flatMap { try? RelayConfiguration(data: $0) } + if !isDormant { + xpc_dictionary_set_string(reply, iGhostVTWireKey.relayFingerprint, relayConfiguration?.fingerprint ?? "") + } if let relayConfiguration { xpc_dictionary_set_string(reply, iGhostVTWireKey.relayName, relayConfiguration.name) xpc_dictionary_set_string(reply, iGhostVTWireKey.relayState, (relay?.state ?? .failed).rawValue) diff --git a/iGhostVTRemote/main.swift b/iGhostVTRemote/main.swift index be04333a..89351874 100644 --- a/iGhostVTRemote/main.swift +++ b/iGhostVTRemote/main.swift @@ -3,7 +3,8 @@ import Dispatch import Foundation // `ighostvtd-remote`: remote access for one host. Spawned and supervised by -// `ighostvtd` (`RemoteSupervisor`) while the switch is on, with the +// `ighostvtd` (`RemoteSupervisor`) while the switch is on — and, dormant, +// while it is off and the app asks about the paired devices — with the // management socket on descriptor 3. It listens on `RemoteAccess.port`, // advertises the host over Bonjour, pairs new devices, and relays each // paired device's requests to the daemon over a connection of its own — @@ -29,14 +30,17 @@ guard RemotePrivileges.dropToSessionUser() else { } var descriptor = IOWire.socketDescriptor +var isDormant = false var arguments = CommandLine.arguments.dropFirst() while let argument = arguments.popFirst() { if argument == IOWire.socketArgument, let value = arguments.popFirst().flatMap(Int32.init) { descriptor = value + } else if argument == IOWire.dormantArgument { + isDormant = true } } -let service = RemoteService(managementDescriptor: descriptor) +let service = RemoteService(managementDescriptor: descriptor, isDormant: isDormant) service.start() withExtendedLifetime(service) { dispatchMain() diff --git a/iGhostVTWidgets/LockScreenView.swift b/iGhostVTWidgets/LockScreenView.swift index 2d393766..fff2807a 100644 --- a/iGhostVTWidgets/LockScreenView.swift +++ b/iGhostVTWidgets/LockScreenView.swift @@ -152,12 +152,12 @@ private struct SessionRow: View { .lineLimit(1) .layoutPriority(1) Spacer(minLength: Spacing.line) - if !session.directory.isEmpty { - Text(verbatim: session.directory) + if !session.directoryName.isEmpty { + Text(verbatim: session.directoryName) .font(.footnote.monospaced()) .opacity(0.5) .lineLimit(1) - .truncationMode(.head) + .truncationMode(.middle) } } .accessibilityElement(children: .combine) @@ -167,16 +167,17 @@ private struct SessionRow: View { @available(iOS 16.2, *) extension TerminalSessionAttributes.Session { - /// The program in front first — it is the daemon's short, stable name - /// — then whatever the shell titled itself, then the configured shell, - /// then the session's number. + /// Whatever the shell titled itself first, as the tab's own title + /// reads (`TerminalTab.displayTitle`), then the program in front — the + /// daemon's short name, which for some programs is only a version + /// number — then the configured shell, then the session's number. var name: String { - if let process, !process.isEmpty { - return process - } if !title.isEmpty { return title } + if let process, !process.isEmpty { + return process + } if !shell.isEmpty { return shell } @@ -185,6 +186,16 @@ extension TerminalSessionAttributes.Session { } return "—" } + + /// The directory's last component, as the tab's subtitle shows it + /// (`TerminalDirectory.name`): `~`, `/` and `@jb` stay whole. + var directoryName: String { + let trimmed = directory.hasSuffix("/") && directory.count > 1 ? String(directory.dropLast()) : directory + guard let slash = trimmed.lastIndex(of: "/"), slash != trimmed.index(before: trimmed.endIndex) else { + return trimmed + } + return String(trimmed[trimmed.index(after: slash)...]) + } } @available(iOS 16.2, *)