diff --git a/AGENTS.md b/AGENTS.md index 05fca46..2c6b572 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/iGhostVT/Application/AppMenus.swift b/iGhostVT/Application/AppMenus.swift index e2dd34d..5715001 100644 --- a/iGhostVT/Application/AppMenus.swift +++ b/iGhostVT/Application/AppMenus.swift @@ -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( diff --git a/iGhostVT/Backend/Remote/RemoteSessionCatalog.swift b/iGhostVT/Backend/Remote/RemoteSessionCatalog.swift index ce6a606..dd8abaf 100644 --- a/iGhostVT/Backend/Remote/RemoteSessionCatalog.swift +++ b/iGhostVT/Backend/Remote/RemoteSessionCatalog.swift @@ -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? + private static let settleDelay: UInt64 = 8_000_000_000 private var poll: Task? private var observers: [NSObjectProtocol] = [] @@ -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 @@ -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() @@ -88,20 +168,19 @@ 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) } } @@ -109,8 +188,6 @@ final class RemoteSessionCatalog: ObservableObject { /// the poll and an opening menu, say — waits for the first rather /// than opening a connection of its own. private var asking: [String: Task] = [:] - /// When each device last answered. - private var answeredAt: [String: Date] = [:] /// Asks one device for its terminals, unless it answered within /// `freshness` seconds. @@ -118,19 +195,23 @@ final class RemoteSessionCatalog: ObservableObject { 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]? { diff --git a/iGhostVT/Backend/Session/LockableTerminalView.swift b/iGhostVT/Backend/Session/LockableTerminalView.swift index f638e53..d874d09 100644 --- a/iGhostVT/Backend/Session/LockableTerminalView.swift +++ b/iGhostVT/Backend/Session/LockableTerminalView.swift @@ -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, diff --git a/iGhostVT/Interface/Support/NewTabMenu.swift b/iGhostVT/Interface/Support/NewTabMenu.swift index ad29538..e8dd58b 100644 --- a/iGhostVT/Interface/Support/NewTabMenu.swift +++ b/iGhostVT/Interface/Support/NewTabMenu.swift @@ -105,7 +105,10 @@ struct NewTabSubmenu: 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) } } @@ -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") } @@ -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. @@ -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: { @@ -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) @@ -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]] @@ -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)) }) diff --git a/iGhostVT/Interface/Support/NewTabMenuElements.swift b/iGhostVT/Interface/Support/NewTabMenuElements.swift index c3c3760..4100de0 100644 --- a/iGhostVT/Interface/Support/NewTabMenuElements.swift +++ b/iGhostVT/Interface/Support/NewTabMenuElements.swift @@ -13,7 +13,9 @@ import UIKit /// rows marked open here for tabs closed since. This one lists what is /// true as it opens: this device's own rows at once, and each paired /// device's terminals once that device has answered — its submenu shows -/// Loading… until then, or until a short wait runs out. On the Mac a +/// Loading… until then, or until a short wait runs out, and then says +/// what it knows (`RemoteSessionCatalog.Listing`): still connecting, not +/// responding, or the terminals. On the Mac a /// device's terminals are what the catalog holds as the menu opens (see /// `openTerminals`). The standalone `+` /// controls (`NewTabMenu`) and the Mac's File ▸ New Tab on Device are built @@ -24,7 +26,13 @@ enum NewTabMenuElements { /// How long a device's terminals wait for its answer before the /// submenu shows the last list it gave; a device that answers later is /// in the next one. - private static let refreshWait: UInt64 = 1_500_000_000 + private static let refreshWait: TimeInterval = 1.5 + /// The wait for a device that has given no list yet — just after + /// launch, the relay and Bonjour are still finding it, and a relayed + /// first connection takes a few seconds on its own. Nothing to show in + /// the meantime but Connecting…. + private static let firstAnswerWait: TimeInterval = 6 + private static let answerPoll: UInt64 = 200_000_000 /// A device that answered this recently is not asked again: the menu /// that has just asked it, opened a second time, uses that answer. private static let answerFreshness: TimeInterval = 5 @@ -64,21 +72,25 @@ enum NewTabMenuElements { } } - /// Returns when the device has answered — the ask out now, or one - /// within `answerFreshness` — or `refreshWait` has passed, whichever is - /// first. The asking carries on past the wait and lands in the catalog. + /// Returns when the device has settled — answered or failed since the + /// menu opened, an answer within `answerFreshness`, or found out of + /// reach once looking is over — or the wait has passed, whichever is + /// first. A device not found yet is asked by the catalog the moment it + /// is (`RemoteSessionCatalog`), so this only watches. The asking + /// carries on past the wait and lands in the catalog. private static func awaitAnswer(fromHostID hostID: String) async { - await withCheckedContinuation { (continuation: CheckedContinuation) in - let gate = ResumeOnce(continuation) + let catalog = RemoteSessionCatalog.shared + let since = Date(timeIntervalSinceNow: -answerFreshness) + let hasList = if case .listed = catalog.listing(of: hostID) { true } else { false } + let deadline = Date(timeIntervalSinceNow: hasList ? refreshWait : firstAnswerWait) + if RemoteHostDirectory.shared.reachablePaired.contains(where: { $0.id == hostID }) { Task { @MainActor in - await RemoteSessionCatalog.shared.refresh(hostID: hostID, freshness: answerFreshness) - gate.resume() - } - Task { @MainActor in - try? await Task.sleep(nanoseconds: refreshWait) - gate.resume() + await catalog.refresh(hostID: hostID, freshness: answerFreshness) } } + while Date() < deadline, !catalog.hasSettled(hostID, since: since) { + try? await Task.sleep(nanoseconds: answerPoll) + } } /// A recent directory's row: its folder's name, the whole spelling @@ -177,11 +189,21 @@ enum NewTabMenuElements { ) -> [UIMenuElement] { hosts.map { host -> UIMenuElement in // A device on another iGhostVT cannot be opened: it is listed, - // greyed, with what to update. - if let theirs = RemoteHostDirectory.mismatchedVersion(ofHostID: host.id) { + // greyed, with what to update. One nothing can reach is greyed + // with that. + let unavailable: String? = if let theirs = RemoteHostDirectory.mismatchedVersion(ofHostID: host.id) { + RemoteVersionText.needsUpdate(theirs: theirs) + } else if RemoteSessionCatalog.shared.listing(of: host.id) == .unreachable { + RemoteSessionCatalog.statusText(.unreachable) + } else { + nil + } + if let unavailable { let action = UIAction(title: host.displayName, image: UIImage(systemName: "network"), attributes: .disabled) { _ in } if #available(iOS 16.0, *) { - action.subtitle = RemoteVersionText.needsUpdate(theirs: theirs) + action.subtitle = unavailable + } else { + action.title = "\(host.displayName) — \(unavailable)" } return action } @@ -235,54 +257,44 @@ enum NewTabMenuElements { #endif } - /// Past this many open terminals a device's group leads with Open All. - static let openAllThreshold = 3 - - /// The Open Terminals group as the catalog knows it now, or nothing - /// when the device holds none. One line per terminal, its title: the - /// process name under it read as noise in a list of agents that all - /// run the same binary. + /// Open All, then the Open Terminals group, as the catalog knows them + /// now; nothing when the device holds none, or a greyed line saying + /// why there is no list yet. Open All comes first so it sits right + /// under New Terminal, greyed once every terminal is open here. One line per terminal, its title: the process name under it + /// read as noise in a list of agents that all run the same binary. private static func openTerminalGroup( of host: PairedRemoteHost, isOpenHere: (PairedRemoteHost, XPCDaemonTransport.SessionSummary) -> Bool, attach: @escaping (String, UInt64) -> Void, ) -> [UIMenuElement] { - let sessions = RemoteSessionCatalog.shared.sessions[host.id] ?? [] + let sessions: [XPCDaemonTransport.SessionSummary] + switch RemoteSessionCatalog.shared.listing(of: host.id) { + case let .listed(rows): + sessions = rows + case let other: + guard let status = RemoteSessionCatalog.statusText(other) else { return [] } + return [UIMenu(title: "", options: .displayInline, children: [ + UIAction(title: status, attributes: .disabled) { _ in }, + ])] + } guard !sessions.isEmpty else { return [] } - var open: [UIMenuElement] = sessions.map { session in + let open: [UIMenuElement] = sessions.map { session in UIAction( title: session.menuTitle, image: UIImage(systemName: isOpenHere(host, session) ? "checkmark" : "terminal"), ) { _ in attach(host.id, session.id) } } - if sessions.count > openAllThreshold { - let closed = sessions.filter { !isOpenHere(host, $0) } - open.insert(UIAction( - title: String(localized: "Open All"), - image: UIImage(systemName: "square.stack"), - attributes: closed.isEmpty ? .disabled : [], - ) { _ in - for session in closed { - attach(host.id, session.id) - } - }, at: 0) + let closed = sessions.filter { !isOpenHere(host, $0) } + let openAll = UIAction( + title: String(localized: "Open All"), + image: UIImage(systemName: "square.stack"), + attributes: closed.isEmpty ? .disabled : [], + ) { _ in + for session in closed { + attach(host.id, session.id) + } } - return [UIMenu(title: String(localized: "Open Terminals"), options: .displayInline, children: open)] - } -} - -/// Resumes its continuation the first time it is asked, and never again. -@MainActor -private final class ResumeOnce { - private var continuation: CheckedContinuation? - - init(_ continuation: CheckedContinuation) { - self.continuation = continuation - } - - func resume() { - continuation?.resume() - continuation = nil + return [openAll, UIMenu(title: String(localized: "Open Terminals"), options: .displayInline, children: open)] } } diff --git a/iGhostVT/Resources/Localizable.xcstrings b/iGhostVT/Resources/Localizable.xcstrings index 4527009..c2f301a 100644 --- a/iGhostVT/Resources/Localizable.xcstrings +++ b/iGhostVT/Resources/Localizable.xcstrings @@ -8018,6 +8018,90 @@ } } }, + "Copy as One Line" : { + "comment" : "Touch selection menu: copy a selection that spans rows with each row trimmed and the line breaks removed", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "نسخ كسطر واحد" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Als eine Zeile kopieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy as One Line" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copiar en una línea" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copier sur une ligne" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copia in una riga" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "1行でコピー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "한 줄로 복사" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copiar em uma linha" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Копировать одной строкой" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sao chép thành một dòng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "拷贝为单行" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "拷貝為單行" + } + } + } + }, "Copy Message" : { "localizations" : { "ar" : { @@ -21275,6 +21359,174 @@ } } }, + "Not Reachable" : { + "comment" : "Menu subtitle: a paired device neither the local network nor the relay can find", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "غير متاح" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicht erreichbar" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not Reachable" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No disponible" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Injoignable" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non raggiungibile" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "接続できません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결할 수 없음" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inacessível" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Недоступно" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể kết nối" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法連線" + } + } + } + }, + "Not Responding" : { + "comment" : "Menu row: a paired device was found but did not answer with its terminals", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا يستجيب" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reagiert nicht" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not Responding" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No responde" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ne répond pas" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non risponde" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "応答なし" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "응답 없음" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Não responde" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не отвечает" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không phản hồi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无响应" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無回應" + } + } + } + }, "Off" : { "localizations" : { "ar" : {