Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -584,8 +584,9 @@ shows the `lock` key as a LOCK column. The `title` key is the title the tab
shows (its reported part, at most once a second, newest wins), so a paired
device's new-tab menu names the terminal word for word as this one does,
on one line — the process under it read as noise in a list of agents that
all run the same binary. Past three terminals a device's group leads with
Open All, which attaches every one not already open here.
all run the same binary. Whenever a device holds a terminal, Open All
sits right under its New Terminal and attaches every one not already open
here — greyed once all are, gone when the device holds none.

`uploadFile` (op 15) puts a file on the daemon's device for a shell there
to read: what a drop on a *remote* tab pastes, since no path on the app's
Expand Down
2 changes: 1 addition & 1 deletion iGhostVT/Application/AppMenus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ enum AppMenus {
}

private static func remoteTabMenuItems() -> [UIMenuElement] {
let hosts = RemoteHostDirectory.shared.reachablePaired
let hosts = RemoteHostDirectory.shared.paired
guard !hosts.isEmpty else {
return [
UIAction(
Expand Down
113 changes: 97 additions & 16 deletions iGhostVT/Backend/Remote/RemoteSessionCatalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,83 @@ import UIKit
/// are asked for ahead of time — as the app comes forward, and every half
/// minute while it stays there with a device to ask. Each ask is a fresh
/// connection to the device, so not more often than that. A device that does
/// not answer keeps its last list until it does.
/// not answer keeps its last list until it does, and a device that drops
/// out of sight for a moment — the relay's list failing once, Bonjour
/// losing it — keeps its list too; only unpairing takes it.
///
/// A menu needs to tell "no terminals" from "not heard from yet": just
/// after launch the relay and Bonjour have not answered, and an empty list
/// then read as a device with nothing open. `listing(of:)` says which.
@MainActor
final class RemoteSessionCatalog: ObservableObject {
static let shared = RemoteSessionCatalog()

@Published private(set) var sessions: [String: [XPCDaemonTransport.SessionSummary]] = [:]
/// How each device's last ask went, and when.
@Published private(set) var outcomes: [String: Outcome] = [:]
/// True for a few seconds after the app comes forward: a paired device
/// not found yet is still being looked for, not gone.
@Published private(set) var isSettling = true

struct Outcome: Equatable {
var answered: Bool
var at: Date
}

/// What a menu can say about one device's terminals.
enum Listing: Equatable {
/// It answered; empty when it holds none.
case listed([XPCDaemonTransport.SessionSummary])
/// Not heard from yet, and an ask is out or it is still being
/// looked for.
case loading
/// Found, but its last ask got no answer.
case notResponding
/// Neither the browser, the relay nor a remembered address has it.
case unreachable
}

func listing(of hostID: String) -> Listing {
let directory = RemoteHostDirectory.shared
let reachable = directory.paired.first { $0.id == hostID }.map(directory.isReachable) ?? false
// A device that answered and is still there (or may be — looking
// has only just begun) shows what it said, even while asked again.
if outcomes[hostID]?.answered == true, reachable || isSettling {
return .listed(sessions[hostID] ?? [])
}
if asking[hostID] != nil || isSettling {
return .loading
}
guard reachable else { return .unreachable }
return outcomes[hostID] == nil ? .loading : .notResponding
}

/// The greyed line a menu shows in place of a device's terminals, or
/// nil for a list.
static func statusText(_ listing: Listing) -> String? {
switch listing {
case .listed:
nil
case .loading:
String(localized: "Connecting…")
case .notResponding:
String(localized: "Not Responding", comment: "Menu row: a paired device was found but did not answer with its terminals")
case .unreachable:
String(localized: "Not Reachable", comment: "Menu subtitle: a paired device neither the local network nor the relay can find")
}
}

/// Whether the device has settled since `date`: answered, failed, or
/// was found to be out of reach once looking was over.
func hasSettled(_ hostID: String, since date: Date) -> Bool {
if let outcome = outcomes[hostID], outcome.at >= date, asking[hostID] == nil {
return true
}
return listing(of: hostID) == .unreachable
}

private var settleTimer: Task<Void, Never>?
private static let settleDelay: UInt64 = 8_000_000_000

private var poll: Task<Void, Never>?
private var observers: [NSObjectProtocol] = []
Expand All @@ -39,6 +110,8 @@ final class RemoteSessionCatalog: ObservableObject {
.map { _ in () }
.merge(with: RemoteHostDirectory.shared.$hosts.map { _ in () })
.merge(with: RemoteHostDirectory.shared.$paired.map { _ in () })
.merge(with: $outcomes.map { _ in () })
.merge(with: $isSettling.map { _ in () })
.merge(with: RecentDirectoryStore.shared.$remoteEntries.map { _ in () })
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.sink { _ in
Expand Down Expand Up @@ -69,6 +142,13 @@ final class RemoteSessionCatalog: ObservableObject {

private func resume() {
guard poll == nil else { return }
isSettling = true
settleTimer?.cancel()
settleTimer = Task { [weak self] in
try? await Task.sleep(nanoseconds: Self.settleDelay)
guard !Task.isCancelled else { return }
self?.isSettling = false
}
poll = Task { [weak self] in
while !Task.isCancelled {
await self?.refresh()
Expand All @@ -88,49 +168,50 @@ final class RemoteSessionCatalog: ObservableObject {
await RemoteHostDirectory.shared.refreshRelay()
let hosts = RemoteHostDirectory.shared.reachablePaired
.filter { RemoteHostDirectory.shared.mismatchedVersion(of: $0.id) == nil }
guard !hosts.isEmpty else {
sessions = [:]
return
}
await withTaskGroup(of: Void.self) { group in
for host in hosts {
group.addTask { await self.refresh(hostID: host.id) }
}
}
// A device no longer offered takes its list with it.
let offered = Set(hosts.map(\.id))
for hostID in sessions.keys where !offered.contains(hostID) {
// An unpaired device takes its list with it; one merely out of
// sight keeps it for when it is back.
let paired = Set(RemoteHostDirectory.shared.paired.map(\.id))
for hostID in sessions.keys where !paired.contains(hostID) {
sessions.removeValue(forKey: hostID)
answeredAt.removeValue(forKey: hostID)
}
for hostID in outcomes.keys where !paired.contains(hostID) {
outcomes.removeValue(forKey: hostID)
}
}

/// The asks out now, by device: a second ask for the same device —
/// the poll and an opening menu, say — waits for the first rather
/// than opening a connection of its own.
private var asking: [String: Task<Void, Never>] = [:]
/// When each device last answered.
private var answeredAt: [String: Date] = [:]

/// Asks one device for its terminals, unless it answered within
/// `freshness` seconds.
func refresh(hostID: String, freshness: TimeInterval = 0) async {
if let ask = asking[hostID] {
return await ask.value
}
if freshness > 0, let answeredAt = answeredAt[hostID], Date().timeIntervalSince(answeredAt) < freshness {
if freshness > 0, let outcome = outcomes[hostID], outcome.answered,
Date().timeIntervalSince(outcome.at) < freshness
{
return
}
let ask = Task { @MainActor in
guard let rows = await Self.list(hostID: hostID) else { return }
answeredAt[hostID] = Date()
if sessions[hostID] != rows {
let rows = await Self.list(hostID: hostID)
if let rows, sessions[hostID] != rows {
sessions[hostID] = rows
}
// Done before the outcome is published, so whoever it wakes
// reads this ask as over.
asking[hostID] = nil
outcomes[hostID] = Outcome(answered: rows != nil, at: Date())
}
asking[hostID] = ask
await ask.value
asking[hostID] = nil
}

private nonisolated static func list(hostID: String) async -> [XPCDaemonTransport.SessionSummary]? {
Expand Down
48 changes: 38 additions & 10 deletions iGhostVT/Backend/Session/LockableTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -415,23 +415,51 @@ final class LockableTerminalView: TerminalView {
routingPaste(super.touchMenuItems(for: context))
}

/// A selection that is a web link, and nothing else, gets Open Link
/// after Copy.
/// A selection over more than one row gets Copy as One Line right
/// after Copy, and one that is a web link, and nothing else, gets
/// Open Link after that.
override func touchSelectionMenuItems(for context: TerminalTouchSelectionMenuContext) -> [UIMenuElement] {
var items = routingPaste(super.touchSelectionMenuItems(for: context))
guard let url = Self.link(in: context.selectedText) else { return items }
let open = UIAction(
title: String(localized: "Open Link"),
image: UIImage(systemName: "safari"),
identifier: UIAction.Identifier("ighostvt.openLink"),
) { _ in
UIApplication.shared.open(url)
var extras: [UIMenuElement] = []
if let line = Self.oneLine(context.selectedText) {
extras.append(UIAction(
title: String(localized: "Copy as One Line"),
image: UIImage(systemName: "arrow.right.to.line"),
identifier: UIAction.Identifier("ighostvt.copyOneLine"),
) { _ in
UIPasteboard.general.string = line
})
}
if let url = Self.link(in: context.selectedText) {
extras.append(UIAction(
title: String(localized: "Open Link"),
image: UIImage(systemName: "safari"),
identifier: UIAction.Identifier("ighostvt.openLink"),
) { _ in
UIApplication.shared.open(url)
})
}
guard !extras.isEmpty else { return items }
let copy = items.firstIndex { ($0 as? UIAction)?.identifier.rawValue == "terminal.copy" }
items.insert(open, at: copy.map { $0 + 1 } ?? 0)
items.insert(contentsOf: extras, at: copy.map { $0 + 1 } ?? 0)
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,
Expand Down
75 changes: 51 additions & 24 deletions iGhostVT/Interface/Support/NewTabMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,10 @@ struct NewTabSubmenu<Label: View>: View {
final class NewTabMenuRows: ObservableObject {
@Published private(set) var choices: NewTabDirectoryChoices?

/// Also asks the devices, as the UIKit menu does as it opens: the rows
/// taken now are what is known, and the answer is in the next touch's.
func take(from tabManager: TabManager) {
NewTabMenuElements.askRemoteDevices()
choices = NewTabDirectoryChoices(tabManager: tabManager)
}
}
Expand Down Expand Up @@ -195,10 +198,10 @@ struct NewTabMenuContent: View {
// as its terminals were many, and read as this device's own.
Section {
ForEach(choices.remoteHosts) { host in
if let theirs = RemoteHostDirectory.mismatchedVersion(ofHostID: host.id) {
// Another iGhostVT there: listed, greyed, with
// what to update.
let title = "\(host.displayName) — \(RemoteVersionText.needsUpdate(theirs: theirs))"
if let unavailable = unavailableText(host) {
// Another iGhostVT there, or out of reach:
// listed, greyed, with why.
let title = "\(host.displayName) — \(unavailable)"
Button {} label: {
SwiftUI.Label(title, systemImage: "network")
}
Expand All @@ -219,6 +222,16 @@ struct NewTabMenuContent: View {
}
}

private func unavailableText(_ host: PairedRemoteHost) -> String? {
if let theirs = RemoteHostDirectory.mismatchedVersion(ofHostID: host.id) {
return RemoteVersionText.needsUpdate(theirs: theirs)
}
if choices.remoteListings[host.id] == .unreachable {
return RemoteSessionCatalog.statusText(.unreachable)
}
return nil
}

/// A paired device: a fresh shell there, one of the terminals it has
/// open — which then opens here and is taken from where it was — or a
/// fresh shell in a directory a tab here was in on it.
Expand All @@ -230,8 +243,30 @@ struct NewTabMenuContent: View {
origin: .remote(hostID: host.id),
)
let recents = choices.remoteRecents[host.id] ?? []
let sessions = choices.remoteSessions[host.id] ?? []
let listing = choices.remoteListings[host.id] ?? .loading
let sessions = if case let .listed(rows) = listing { rows } else { [XPCDaemonTransport.SessionSummary]() }
// Right under New Terminal whenever the device holds a terminal,
// greyed once every one is open here.
if !sessions.isEmpty {
let closed = sessions.filter { !isOpenHere($0, on: host) }
Button {
for session in closed {
tabManager.openRemoteTab(attachingTo: session.id, hostID: host.id)
}
onOpen()
} label: {
SwiftUI.Label("Open All", systemImage: "square.stack")
}
.disabled(closed.isEmpty)
}
if let status = RemoteSessionCatalog.statusText(listing) {
Section {
Button {} label: {
Text(verbatim: status)
}
.disabled(true)
}
} else if !sessions.isEmpty {
Section {
remoteSessionRows(sessions, on: host)
} header: {
Expand All @@ -258,23 +293,10 @@ struct NewTabMenuContent: View {
}
}

@ViewBuilder
private func remoteSessionRows(
_ sessions: [XPCDaemonTransport.SessionSummary],
on host: PairedRemoteHost,
) -> some View {
if sessions.count > NewTabMenuElements.openAllThreshold {
let closed = sessions.filter { !isOpenHere($0, on: host) }
Button {
for session in closed {
tabManager.openRemoteTab(attachingTo: session.id, hostID: host.id)
}
onOpen()
} label: {
SwiftUI.Label("Open All", systemImage: "square.stack")
}
.disabled(closed.isEmpty)
}
ForEach(sessions) { session in
Button {
tabManager.openRemoteTab(attachingTo: session.id, hostID: host.id)
Expand Down Expand Up @@ -336,11 +358,14 @@ struct NewTabDirectoryChoices {

var openTabs: [OpenTab]
var recents: [TerminalDirectory]
/// Paired devices on the network right now (remote access): a fresh
/// shell there, in its user's home, or a terminal it has open.
/// Every paired device (remote access): a fresh shell there, in its
/// user's home, or a terminal it has open. One not found yet is listed
/// as connecting, and one nothing can reach greyed — leaving them out
/// made the menu forget a device for the seconds after launch before
/// the relay or Bonjour had answered.
var remoteHosts: [PairedRemoteHost]
/// The terminals each of those has open, as last asked.
var remoteSessions: [String: [XPCDaemonTransport.SessionSummary]]
/// What each of those has said about its terminals, as last asked.
var remoteListings: [String: RemoteSessionCatalog.Listing]
/// Where tabs here have been on each of those, by host id.
var remoteRecents: [String: [TerminalDirectory]]

Expand Down Expand Up @@ -368,8 +393,10 @@ struct NewTabDirectoryChoices {
remoteHosts: RemoteHostDirectory,
remoteSessions: RemoteSessionCatalog,
) {
self.remoteHosts = remoteHosts.reachablePaired
self.remoteSessions = remoteSessions.sessions
self.remoteHosts = remoteHosts.paired
remoteListings = Dictionary(uniqueKeysWithValues: self.remoteHosts.map { host in
(host.id, remoteSessions.listing(of: host.id))
})
remoteRecents = Dictionary(uniqueKeysWithValues: self.remoteHosts.map { host in
(host.id, store.menuDirectories(onHost: host.id))
})
Expand Down
Loading
Loading