diff --git a/Packages/CmuxMobileBrowser/Package.swift b/Packages/CmuxMobileBrowser/Package.swift new file mode 100644 index 000000000000..150d165afce4 --- /dev/null +++ b/Packages/CmuxMobileBrowser/Package.swift @@ -0,0 +1,47 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxMobileBrowser", + platforms: [ + .iOS(.v18), + .macOS(.v14), + ], + products: [ + .library( + name: "CmuxMobileBrowser", + targets: ["CmuxMobileBrowser"] + ), + ], + dependencies: [ + // Localized-string helpers (`L10n`). `CmuxMobileSupport` is a leaf with + // no dependencies, so the browser package stays low in the DAG. + .package(path: "../CmuxMobileSupport"), + ], + targets: [ + // A self-contained, phone-local browser surface. P1 browser state never + // touches the Mac, so this package sits low in the DAG: it depends only + // on the leaf `CmuxMobileSupport` and links Foundation/WebKit/SwiftUI. + .target( + name: "CmuxMobileBrowser", + dependencies: [ + "CmuxMobileSupport", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxMobileBrowserTests", + dependencies: ["CmuxMobileBrowser"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserSurfaceState.swift b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserSurfaceState.swift new file mode 100644 index 000000000000..f30f295c115e --- /dev/null +++ b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserSurfaceState.swift @@ -0,0 +1,193 @@ +public import Foundation +import Observation + +/// The observable state of a single phone-local browser pane. +/// +/// This is the mobile analogue of a terminal surface, but its lifecycle is +/// entirely local: there is no Mac-side counterpart in P1. The view layer +/// (`MobileBrowserView`) drives this from `WKWebView` callbacks; the address +/// bar reads `addressText`, the chrome reads `canGoBack`/`canGoForward`/ +/// `isLoading`/`estimatedProgress`, and a pending ``loadRequest`` tells the +/// representable what URL to load next. +/// +/// It is `@MainActor @Observable` (not `ObservableObject`/`@Published`), so +/// SwiftUI tracks individual property reads and the `WKWebView` coordinator can +/// mutate it directly on the main actor. +@MainActor +@Observable +public final class BrowserSurfaceState: Identifiable { + /// A stable identifier for a browser surface, so SwiftUI can key the hosting + /// representable and tear down the `WKWebView` when the surface changes. + public struct ID: RawRepresentable, Hashable, Sendable { + /// The backing identifier string. + public var rawValue: String + + /// Creates an identifier from its raw string value. + /// - Parameter rawValue: The backing identifier. + public init(rawValue: String) { + self.rawValue = rawValue + } + } + + /// A history/navigation command the chrome can request against the hosted + /// web view. + public enum NavigationCommand: Equatable, Sendable { + /// Navigate back one history entry. + case goBack + /// Navigate forward one history entry. + case goForward + /// Reload the current page. + case reload + /// Stop the in-flight navigation. + case stopLoading + } + + /// The surface's stable identifier. + public let id: ID + + /// The text currently shown in (or being edited in) the address bar. The + /// view keeps this in sync with the live URL when not editing. + public var addressText: String + + /// Whether the user is currently editing the address bar. While `true`, the + /// web view's URL/navigation callbacks must not overwrite ``addressText``, + /// otherwise a redirect or in-flight URL change clobbers the user's typing. + public var isAddressEditing: Bool + + /// The page's reported title, or `nil` before the first navigation + /// resolves a title. + public var title: String? + + /// The page's current committed URL, or `nil` before the first navigation. + public var currentURL: URL? + + /// Whether a navigation is in flight. Drives the progress indicator and the + /// reload/stop button affordance. + public var isLoading: Bool + + /// The latest navigation progress in `0...1`. Only meaningful while + /// ``isLoading`` is `true`. + public var estimatedProgress: Double + + /// Whether the web view can navigate back in its history. + public var canGoBack: Bool + + /// Whether the web view can navigate forward in its history. + public var canGoForward: Bool + + /// A user-facing error message for the most recent failed navigation, or + /// `nil` when the last navigation succeeded or none has occurred. + public var lastErrorMessage: String? + + /// A pending URL the representable should load, set by ``load(_:)``. The + /// view consumes it via ``consumeLoadRequest()`` and clears it so the same + /// request is not replayed on re-render. + public private(set) var loadRequest: URL? + + /// A pending history/navigation command the representable should run against + /// the `WKWebView` (back, forward, reload, stop). The view consumes it via + /// ``consumeCommand()`` and clears it so the same command runs once. + public private(set) var pendingCommand: NavigationCommand? + + /// Creates a browser surface state. + /// + /// - Parameters: + /// - id: The surface's stable identifier. + /// - initialURL: An optional URL to load when the surface first appears. + /// When provided, ``loadRequest`` and ``addressText`` are seeded from it. + public init(id: ID, initialURL: URL? = nil) { + self.id = id + self.addressText = initialURL?.absoluteString ?? "" + self.isAddressEditing = false + self.title = nil + self.currentURL = initialURL + self.isLoading = false + self.estimatedProgress = 0 + self.canGoBack = false + self.canGoForward = false + self.lastErrorMessage = nil + self.loadRequest = initialURL + } + + /// Request a navigation to `url`. Sets ``loadRequest`` for the view to pick + /// up and seeds the address bar so it reflects the target immediately. + /// + /// - Parameter url: The URL to load. + public func load(_ url: URL) { + loadRequest = url + addressText = url.absoluteString + lastErrorMessage = nil + } + + /// Resolve and load whatever is currently in the address bar, returning + /// whether a loadable URL was produced. + /// + /// - Parameter resolver: The resolver used to interpret the address text. + /// Defaults to ``BrowserURLResolver`` semantics. + /// - Returns: `true` if a URL was resolved and a load was requested. + @discardableResult + public func submitAddress(using resolve: (String) -> URL? = { BrowserURLResolver.resolve($0) }) -> Bool { + guard let url = resolve(addressText) else { return false } + load(url) + return true + } + + /// Consume the pending ``loadRequest``, returning it and clearing it so the + /// view loads each request exactly once. + /// + /// Returns `nil` without mutating when nothing is pending, so the + /// representable's `updateUIView` (which calls this on every refresh) does + /// not write observable state on no-op refreshes and trigger a re-render + /// loop while a page is loading. + /// + /// - Returns: The pending load URL, or `nil` if none is pending. + public func consumeLoadRequest() -> URL? { + guard let request = loadRequest else { return nil } + loadRequest = nil + return request + } + + /// Request a history/navigation command (back, forward, reload, stop). The + /// representable runs it against the web view and clears it. + /// + /// - Parameter command: The command to run. + public func request(_ command: NavigationCommand) { + pendingCommand = command + } + + /// Consume the pending navigation command, returning it and clearing it so + /// the view runs each command exactly once. + /// + /// Returns `nil` without mutating when nothing is pending, for the same + /// no-op-refresh reason as ``consumeLoadRequest()``. + /// + /// - Returns: The pending command, or `nil` if none is pending. + public func consumeCommand() -> NavigationCommand? { + guard let command = pendingCommand else { return nil } + pendingCommand = nil + return command + } + + /// Mark the start of a navigation: loading begins, progress resets, and any + /// prior error is cleared. + public func navigationDidStart() { + isLoading = true + estimatedProgress = 0 + lastErrorMessage = nil + } + + /// Mark a successful navigation finish: loading ends and progress completes. + public func navigationDidFinish() { + isLoading = false + estimatedProgress = 1 + } + + /// Mark a navigation failure with a user-facing message. + /// + /// - Parameter message: The error description to surface in the chrome. + public func navigationDidFail(message: String) { + isLoading = false + estimatedProgress = 0 + lastErrorMessage = message + } +} diff --git a/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserSurfaceStore.swift b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserSurfaceStore.swift new file mode 100644 index 000000000000..93473f247ffb --- /dev/null +++ b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserSurfaceStore.swift @@ -0,0 +1,92 @@ +public import Foundation +import Observation + +/// Owns the phone-local browser surfaces, one optional active surface per +/// workspace. +/// +/// Browser state is deliberately kept out of `MobileShellComposite` and +/// `MobileWorkspacePreview`: a terminal preview is rebuilt from the Mac on every +/// `workspace.updated` sync, so storing a browser there would clobber it on the +/// next sync. This store is the local home for browser panes; it is injected +/// into the shell UI alongside the terminal store and survives Mac re-syncs. +/// +/// Each workspace has at most one browser surface in P1 (single pane, not +/// multi-tab). Opening a browser sets the workspace's active surface; closing it +/// clears it and the UI falls back to the terminal. +@MainActor +@Observable +public final class BrowserSurfaceStore { + /// The active browser surface per workspace id, keyed by the workspace's raw + /// identifier string. Absent keys mean the workspace shows its terminal. + private var surfacesByWorkspace: [String: BrowserSurfaceState] + + /// Produces a fresh, unique surface id. Injected so tests are deterministic. + private let makeSurfaceID: () -> BrowserSurfaceState.ID + + /// The URL a freshly opened browser loads. Injected so the default is + /// configurable and tests stay hermetic. + private let defaultURL: URL? + + /// Creates a browser surface store. + /// + /// - Parameters: + /// - defaultURL: The URL a newly opened browser loads. Defaults to + /// DuckDuckGo's homepage. + /// - makeSurfaceID: A factory for unique surface ids. Defaults to a + /// UUID-backed generator. + public init( + defaultURL: URL? = URL(string: "https://duckduckgo.com/"), + makeSurfaceID: @escaping () -> BrowserSurfaceState.ID = { + BrowserSurfaceState.ID(rawValue: UUID().uuidString) + } + ) { + self.surfacesByWorkspace = [:] + self.makeSurfaceID = makeSurfaceID + self.defaultURL = defaultURL + } + + /// The active browser surface for a workspace, if one is open. + /// + /// - Parameter workspaceID: The workspace's raw identifier string. + /// - Returns: The active surface, or `nil` when the workspace shows its + /// terminal. + public func activeBrowser(for workspaceID: String) -> BrowserSurfaceState? { + surfacesByWorkspace[workspaceID] + } + + /// Whether a workspace currently has a browser pane open. + /// + /// - Parameter workspaceID: The workspace's raw identifier string. + /// - Returns: `true` if a browser surface is active for the workspace. + public func hasBrowser(for workspaceID: String) -> Bool { + surfacesByWorkspace[workspaceID] != nil + } + + /// Open (or reveal the existing) browser pane for a workspace. + /// + /// If the workspace already has a browser surface, that same surface is + /// returned so the current page is restored when switching away and back + /// (the surface's `currentURL` is reloaded into a fresh web view on + /// re-attach). In P1, full back/forward history is not preserved across + /// remounts; persisting the live WebKit session and history is P2. A new + /// surface loads ``defaultURL``. + /// + /// - Parameter workspaceID: The workspace's raw identifier string. + /// - Returns: The active browser surface for the workspace. + @discardableResult + public func openBrowser(for workspaceID: String) -> BrowserSurfaceState { + if let existing = surfacesByWorkspace[workspaceID] { + return existing + } + let surface = BrowserSurfaceState(id: makeSurfaceID(), initialURL: defaultURL) + surfacesByWorkspace[workspaceID] = surface + return surface + } + + /// Close the browser pane for a workspace, returning the UI to its terminal. + /// + /// - Parameter workspaceID: The workspace's raw identifier string. + public func closeBrowser(for workspaceID: String) { + surfacesByWorkspace.removeValue(forKey: workspaceID) + } +} diff --git a/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserURLResolver.swift b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserURLResolver.swift new file mode 100644 index 000000000000..91daf909e377 --- /dev/null +++ b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserURLResolver.swift @@ -0,0 +1,173 @@ +public import Foundation + +/// Turns whatever a user types in the address bar into a loadable `URL`. +/// +/// The phone address bar accepts three kinds of input, and this resolver maps +/// each to a concrete request, mirroring how a normal mobile browser omnibox +/// behaves: +/// +/// - A full URL with a scheme (`https://example.com`) loads verbatim. +/// - A bare host or path that looks like a domain (`example.com`, +/// `localhost:3000`) is treated as an `https://` URL. +/// - Anything else (free text, multiple words) becomes a web search. +/// +/// It is a pure value type with no I/O so it can be unit-tested in isolation, +/// which is where the address-bar correctness actually lives. +public struct BrowserURLResolver { + /// The default search-engine query template. `%@` is replaced with the + /// percent-encoded query. + public static let defaultSearchTemplate = "https://duckduckgo.com/?q=%@" + + /// The resolver is a unit of pure static functions; it is never + /// instantiated. + private init() {} + + /// Resolve raw address-bar text into a URL to load. + /// + /// - Parameters: + /// - input: The raw text the user submitted. + /// - searchTemplate: The search-URL template used when `input` is not a + /// URL. `%@` is replaced with the percent-encoded query. Defaults to + /// ``defaultSearchTemplate``. + /// - Returns: A URL to load, or `nil` when `input` is empty after trimming. + public static func resolve( + _ input: String, + searchTemplate: String = defaultSearchTemplate + ) -> URL? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if let schemed = schemedURL(from: trimmed) { + return schemed + } + if looksLikeHost(trimmed) { + // Local dev servers (localhost, loopback, private LAN) listen on + // plain HTTP, and opening a local dev server is a central cmux + // workflow. Forcing HTTPS would break them, so default those to + // `http://` and everything else to `https://`. This matches the + // desktop browser resolver's localhost/loopback special-casing. + let scheme = isLocalHost(trimmed) ? "http" : "https" + // A bare (unbracketed) IPv6 literal must be bracketed for the URL to + // parse: `::1` -> `http://[::1]`. + let authority = needsIPv6Brackets(trimmed) ? "[\(trimmed)]" : trimmed + if let host = URL(string: "\(scheme)://\(authority)") { + return host + } + } + return searchURL(for: trimmed, template: searchTemplate) + } + + /// Whether `input` is an unbracketed IPv6 literal (two or more colons, not + /// already bracketed and without a path), so it must be wrapped in `[...]` + /// to form a valid URL authority. + private static func needsIPv6Brackets(_ input: String) -> Bool { + guard !input.hasPrefix("["), !input.contains("/") else { return false } + return input.filter { $0 == ":" }.count >= 2 + } + + /// Characters allowed unescaped when encoding a query-string *value*. + /// + /// `.urlQueryAllowed` is too permissive for a value substituted into + /// `?q=...`: it leaves the parameter separators `&`, `=`, `+`, `?`, and `#` + /// unescaped, so a search like `AT&T earnings` or `C++` would be split or + /// reinterpreted by the endpoint. Subtracting those separators preserves the + /// typed query verbatim. + private static let queryValueAllowed: CharacterSet = { + var set = CharacterSet.urlQueryAllowed + set.remove(charactersIn: "&=+?#") + return set + }() + + /// Build a search URL for free-text input. + /// + /// - Parameters: + /// - query: The user's free-text query. + /// - template: The search-URL template; `%@` is replaced with the + /// percent-encoded query (encoded as a query-string value, so query + /// separators in the input are escaped). + /// - Returns: The search URL, or `nil` if the template is malformed. + public static func searchURL(for query: String, template: String = defaultSearchTemplate) -> URL? { + let encoded = query.addingPercentEncoding( + withAllowedCharacters: queryValueAllowed + ) ?? query + return URL(string: template.replacingOccurrences(of: "%@", with: encoded)) + } + + /// A URL with an explicit, http(s)-like scheme, or `nil` if `input` has no + /// usable scheme. Schemes other than `http`/`https` are rejected so a typed + /// `file:` or `javascript:` cannot be loaded from the address bar. + private static func schemedURL(from input: String) -> URL? { + guard let components = URLComponents(string: input), + let scheme = components.scheme?.lowercased() else { + return nil + } + guard scheme == "http" || scheme == "https" else { return nil } + // A scheme with no host (`https://`) is not loadable; fall through to + // the host/search heuristics by reporting no schemed URL. + guard let host = components.host, !host.isEmpty else { return nil } + return components.url + } + + /// Whether `input` (which has no scheme) looks like a host the user wants to + /// visit rather than a search query. A token is host-like when it has no + /// spaces and contains a dot (`a.com`) or is a known local host + /// (`localhost`, optionally with a port or path). + private static func looksLikeHost(_ input: String) -> Bool { + guard !input.contains(" ") else { return false } + let host = bareHost(of: input) + if host == "localhost" { return true } + // An IPv6 literal (the bare-host extraction keeps the colons) is a host, + // e.g. `::1` or a bracketed `[::1]:3000`. + if host.filter({ $0 == ":" }).count >= 2 { return true } + // A dotted token with a non-empty label on each side of the last dot + // (so a trailing-dot or leading-dot string is not treated as a host). + guard let lastDot = host.lastIndex(of: ".") else { return false } + let afterDot = host[host.index(after: lastDot)...] + let beforeDot = host[.. Bool { + let host = bareHost(of: input).lowercased() + if host == "localhost" || host == "::1" { return true } + let octets = host.split(separator: ".", omittingEmptySubsequences: false) + guard octets.count == 4, octets.allSatisfy({ UInt8($0) != nil }) else { return false } + let values = octets.compactMap { Int($0) } + guard values.count == 4 else { return false } + if values[0] == 127 { return true } // 127.0.0.0/8 loopback + if values[0] == 10 { return true } // 10.0.0.0/8 + if values[0] == 192 && values[1] == 168 { return true } // 192.168.0.0/16 + if values[0] == 172 && (16...31).contains(values[1]) { return true } // 172.16.0.0/12 + return false + } + + /// The bare host of `input` (no scheme, no port, no path). + /// + /// Strips the path (first `/`), then the port. A bracketed IPv6 literal + /// (`[::1]:3000`) keeps the address between the brackets; an unbracketed + /// token containing multiple colons is treated as a bare IPv6 literal + /// (`::1`) so the loopback comparison can match. A single-colon token is a + /// `host:port` pair and keeps only the host. + private static func bareHost(of input: String) -> String { + let hostAndPort = input.split(separator: "/", maxSplits: 1).first.map(String.init) ?? input + if hostAndPort.hasPrefix("[") { + // Bracketed IPv6: take everything inside the brackets. + if let close = hostAndPort.firstIndex(of: "]") { + return String(hostAndPort[hostAndPort.index(after: hostAndPort.startIndex)..= 2 { + // Unbracketed multi-colon token: a bare IPv6 literal, no port. + return hostAndPort + } + // host or host:port. + return hostAndPort.split(separator: ":", maxSplits: 1).first.map(String.init) ?? hostAndPort + } +} diff --git a/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileBrowserPane.swift b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileBrowserPane.swift new file mode 100644 index 000000000000..48529cd3aaae --- /dev/null +++ b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileBrowserPane.swift @@ -0,0 +1,136 @@ +#if canImport(UIKit) +public import SwiftUI +import CmuxMobileSupport + +/// A complete phone browser pane: a navigation chrome bar (back / forward / +/// reload / address field) over a hosted `WKWebView`, plus a determinate +/// loading line. +/// +/// This is the browser sibling of the terminal surface view. It is driven +/// entirely by an `@Observable` ``BrowserSurfaceState``: the chrome reads the +/// state's flags and writes navigation commands back into it, and +/// ``MobileBrowserView`` carries those into the web view. A close action +/// returns the workspace to its terminal. +public struct MobileBrowserPane: View { + /// The browser surface state this pane drives and reflects. + @State private var state: BrowserSurfaceState + + /// Whether the address field currently has editing focus. While editing, + /// the field shows the user's in-progress text rather than the live URL. + @FocusState private var isAddressFocused: Bool + + /// Invoked when the user closes the browser pane. + private let onClose: () -> Void + + /// Creates a browser pane. + /// - Parameters: + /// - state: The browser surface state to host. + /// - onClose: Invoked when the user dismisses the pane. + public init(state: BrowserSurfaceState, onClose: @escaping () -> Void) { + _state = State(initialValue: state) + self.onClose = onClose + } + + public var body: some View { + VStack(spacing: 0) { + chromeBar + progressLine + MobileBrowserView(state: state) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(Color(.systemBackground)) + } + + private var chromeBar: some View { + HStack(spacing: 12) { + Button { + state.request(.goBack) + } label: { + Image(systemName: "chevron.backward") + } + .disabled(!state.canGoBack) + .accessibilityLabel(L10n.string("mobile.browser.back", defaultValue: "Back")) + .accessibilityIdentifier("MobileBrowserBackButton") + + Button { + state.request(.goForward) + } label: { + Image(systemName: "chevron.forward") + } + .disabled(!state.canGoForward) + .accessibilityLabel(L10n.string("mobile.browser.forward", defaultValue: "Forward")) + .accessibilityIdentifier("MobileBrowserForwardButton") + + addressField + + reloadOrStopButton + + Button(action: onClose) { + Image(systemName: "xmark") + } + .accessibilityLabel(L10n.string("mobile.browser.close", defaultValue: "Close Browser")) + .accessibilityIdentifier("MobileBrowserCloseButton") + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.bar) + } + + private var addressField: some View { + TextField( + L10n.string("mobile.browser.addressPlaceholder", defaultValue: "Search or enter address"), + text: $state.addressText + ) + .textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(.webSearch) + .submitLabel(.go) + .focused($isAddressFocused) + .onChange(of: isAddressFocused) { _, focused in + // Mirror editing focus into the state so the web view's URL observer + // does not overwrite in-progress typing (see `isAddressEditing`). + state.isAddressEditing = focused + } + .onSubmit { + if state.submitAddress() { + isAddressFocused = false + } + } + .accessibilityIdentifier("MobileBrowserAddressField") + } + + @ViewBuilder + private var reloadOrStopButton: some View { + if state.isLoading { + Button { + state.request(.stopLoading) + } label: { + Image(systemName: "xmark.circle") + } + .accessibilityLabel(L10n.string("mobile.browser.stop", defaultValue: "Stop")) + .accessibilityIdentifier("MobileBrowserStopButton") + } else { + Button { + state.request(.reload) + } label: { + Image(systemName: "arrow.clockwise") + } + .accessibilityLabel(L10n.string("mobile.browser.reload", defaultValue: "Reload")) + .accessibilityIdentifier("MobileBrowserReloadButton") + } + } + + @ViewBuilder + private var progressLine: some View { + if state.isLoading { + ProgressView(value: state.estimatedProgress) + .progressViewStyle(.linear) + .frame(height: 2) + .accessibilityIdentifier("MobileBrowserProgress") + } else { + Color.clear.frame(height: 2) + } + } +} +#endif diff --git a/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileBrowserView.swift b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileBrowserView.swift new file mode 100644 index 000000000000..55257136c839 --- /dev/null +++ b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileBrowserView.swift @@ -0,0 +1,228 @@ +#if canImport(UIKit) +public import SwiftUI +public import UIKit +public import WebKit + +/// SwiftUI wrapper that hosts a single `WKWebView` for a ``BrowserSurfaceState``. +/// +/// This is the browser sibling of the terminal's `GhosttySurfaceRepresentable`: +/// a `UIViewRepresentable` whose coordinator owns the web view, observes its +/// navigation key paths, and mirrors them into the `@Observable` surface state +/// so the SwiftUI chrome (address bar, progress, back/forward) stays in sync. +/// +/// Loading progress and navigation flags come from `NSKeyValueObservation` on +/// the web view plus `WKNavigationDelegate` callbacks rather than Combine, to +/// fit the `@Observable` model and avoid `ObservableObject`. +public struct MobileBrowserView: UIViewRepresentable { + /// The state this view drives and reflects. + public let state: BrowserSurfaceState + + /// Creates a browser view bound to a surface state. + /// - Parameter state: The browser surface state to host. + public init(state: BrowserSurfaceState) { + self.state = state + } + + /// Builds the coordinator that owns the web view and its observations. + /// - Returns: A new ``Coordinator``. + public func makeCoordinator() -> Coordinator { + Coordinator(state: state) + } + + /// Creates and configures the hosted `WKWebView`. + /// - Parameter context: The representable context carrying the coordinator. + /// - Returns: The configured web view. + public func makeUIView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + // Default persistent data store: cookies/localStorage persist on the + // phone across launches. Cross-device sync with the Mac is P2. + configuration.websiteDataStore = .default() + configuration.allowsInlineMediaPlayback = true + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.allowsBackForwardNavigationGestures = true + webView.navigationDelegate = context.coordinator + webView.uiDelegate = context.coordinator + context.coordinator.attach(webView: webView) + return webView + } + + /// Pushes any pending load request and navigation command from the state + /// into the web view. + /// - Parameters: + /// - uiView: The hosted web view. + /// - context: The representable context carrying the coordinator. + public func updateUIView(_ uiView: WKWebView, context: Context) { + context.coordinator.applyPendingWork() + } + + /// Tears down the coordinator's observations and web-view delegate. + /// - Parameters: + /// - uiView: The hosted web view. + /// - coordinator: The coordinator to detach. + public static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) { + coordinator.detach() + } + + /// Owns the `WKWebView`, observes its navigation key paths, and bridges + /// navigation callbacks into the `@Observable` ``BrowserSurfaceState``. + @MainActor + public final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate { + private let state: BrowserSurfaceState + private weak var webView: WKWebView? + private var observations: [NSKeyValueObservation] = [] + + /// Creates a coordinator for a surface state. + /// - Parameter state: The surface state to mirror web-view changes into. + public init(state: BrowserSurfaceState) { + self.state = state + super.init() + } + + /// Binds the coordinator to a web view: registers key-value observations + /// and kicks off the first pending load. + /// - Parameter webView: The web view to observe and drive. + func attach(webView: WKWebView) { + self.webView = webView + observe(webView) + // A surface can be re-attached to a fresh WKWebView when SwiftUI + // remounts the representable (switching workspaces, hiding/showing + // the browser). The surface state survives, but the web view does + // not, so restore the last committed URL on re-attach to honor the + // "current page is restored on return" promise. First mount already + // has a pending initial-URL load, so guard against a double-load. + let hadPendingLoad = state.loadRequest != nil + applyPendingWork() + if !hadPendingLoad, webView.url == nil, let restore = state.currentURL { + webView.load(URLRequest(url: restore)) + } + } + + /// Runs any pending load request and navigation command from the state + /// against the web view. + func applyPendingWork() { + guard let webView else { return } + if let url = state.consumeLoadRequest() { + webView.load(URLRequest(url: url)) + } + if let command = state.consumeCommand() { + run(command, on: webView) + } + } + + private func run(_ command: BrowserSurfaceState.NavigationCommand, on webView: WKWebView) { + switch command { + case .goBack: + webView.goBack() + case .goForward: + webView.goForward() + case .reload: + webView.reload() + case .stopLoading: + webView.stopLoading() + } + } + + /// Cancels all observations and releases the web view. Called on + /// dismantle so the surface leaves no dangling KVO registrations. + func detach() { + observations.forEach { $0.invalidate() } + observations.removeAll() + webView?.navigationDelegate = nil + webView?.uiDelegate = nil + webView = nil + } + + private func observe(_ webView: WKWebView) { + // Each observer mirrors one web-view property into the @Observable + // state on the main actor. `options: [.initial]` is intentionally + // omitted so the seeded state is not overwritten before first load. + observations = [ + webView.observe(\.estimatedProgress) { [state] webView, _ in + MainActor.assumeIsolated { + state.estimatedProgress = webView.estimatedProgress + } + }, + webView.observe(\.title) { [state] webView, _ in + MainActor.assumeIsolated { + if let title = webView.title, !title.isEmpty { + state.title = title + } + } + }, + webView.observe(\.url) { [state] webView, _ in + MainActor.assumeIsolated { + state.currentURL = webView.url + // Do not clobber the user's in-progress typing: only + // mirror the live URL into the address bar when the user + // is not editing it. + if let url = webView.url, !state.isAddressEditing { + state.addressText = url.absoluteString + } + } + }, + webView.observe(\.canGoBack) { [state] webView, _ in + MainActor.assumeIsolated { state.canGoBack = webView.canGoBack } + }, + webView.observe(\.canGoForward) { [state] webView, _ in + MainActor.assumeIsolated { state.canGoForward = webView.canGoForward } + }, + ] + } + + // MARK: - WKNavigationDelegate + + public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + state.navigationDidStart() + } + + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + state.navigationDidFinish() + if let title = webView.title, !title.isEmpty { + state.title = title + } + } + + public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error) { + failNavigation(with: error) + } + + public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) { + failNavigation(with: error) + } + + private func failNavigation(with error: any Error) { + // A cancelled load reports `NSURLErrorCancelled`. This is not a + // failure to surface; it happens on a user stop AND when a new + // navigation replaces an in-flight one. Mirror the web view's real + // `isLoading` rather than forcing `false`, so the chrome stays in the + // loading state when a replacement navigation is still in flight. + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { + state.isLoading = webView?.isLoading ?? false + if !state.isLoading { state.estimatedProgress = 0 } + return + } + state.navigationDidFail(message: error.localizedDescription) + } + + // MARK: - WKUIDelegate + + public func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + // P1 is a single-pane browser with no tabs, so `target="_blank"` / + // `window.open` links (which arrive with a nil `targetFrame`) would + // otherwise be silently dropped. Load them in the current web view + // instead so external/doc/auth links still navigate. Returning nil + // tells WebKit not to create a new web view. + if navigationAction.targetFrame == nil { + webView.load(navigationAction.request) + } + return nil + } + } +} +#endif diff --git a/Packages/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/BrowserSurfaceStateTests.swift b/Packages/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/BrowserSurfaceStateTests.swift new file mode 100644 index 000000000000..aa5fa708ee70 --- /dev/null +++ b/Packages/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/BrowserSurfaceStateTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing + +@testable import CmuxMobileBrowser + +/// Pure nav-state and loading transitions for a browser surface, exercised +/// without a `WKWebView`. +@MainActor +@Suite struct BrowserSurfaceStateTests { + private func makeState(initialURL: URL? = nil) -> BrowserSurfaceState { + BrowserSurfaceState(id: .init(rawValue: "test"), initialURL: initialURL) + } + + @Test func initialURLSeedsAddressAndLoadRequest() { + let url = URL(string: "https://example.com")! + let state = makeState(initialURL: url) + #expect(state.addressText == "https://example.com") + #expect(state.currentURL == url) + #expect(state.consumeLoadRequest() == url) + // Consumed exactly once. + #expect(state.consumeLoadRequest() == nil) + } + + @Test func emptyInitialStateHasNoPendingWork() { + let state = makeState() + #expect(state.addressText.isEmpty) + #expect(state.consumeLoadRequest() == nil) + #expect(state.consumeCommand() == nil) + #expect(state.isLoading == false) + #expect(state.canGoBack == false) + #expect(state.canGoForward == false) + #expect(state.isAddressEditing == false) + } + + @Test func consumeHelpersAreIdempotentWhenEmpty() { + // Calling the consumers repeatedly with nothing pending must keep + // returning nil; the representable calls these on every refresh and they + // must not churn observable state on no-op refreshes. + let state = makeState() + for _ in 0..<3 { + #expect(state.consumeLoadRequest() == nil) + #expect(state.consumeCommand() == nil) + } + } + + @Test func loadSetsRequestAndAddressAndClearsError() { + let state = makeState() + state.navigationDidFail(message: "boom") + let url = URL(string: "https://cmux.dev")! + state.load(url) + #expect(state.addressText == "https://cmux.dev") + #expect(state.lastErrorMessage == nil) + #expect(state.consumeLoadRequest() == url) + } + + @Test func submitAddressResolvesAndRequestsLoad() { + let state = makeState() + state.addressText = "example.com" + let didLoad = state.submitAddress() + #expect(didLoad) + #expect(state.consumeLoadRequest()?.host == "example.com") + } + + @Test func submitAddressReturnsFalseForEmpty() { + let state = makeState() + state.addressText = " " + #expect(state.submitAddress() == false) + #expect(state.consumeLoadRequest() == nil) + } + + @Test func navigationLifecycleTransitions() { + let state = makeState() + + state.navigationDidStart() + #expect(state.isLoading) + #expect(state.estimatedProgress == 0) + #expect(state.lastErrorMessage == nil) + + state.estimatedProgress = 0.5 + state.navigationDidFinish() + #expect(state.isLoading == false) + #expect(state.estimatedProgress == 1) + } + + @Test func navigationFailureSurfacesMessageAndStopsLoading() { + let state = makeState() + state.navigationDidStart() + state.navigationDidFail(message: "no network") + #expect(state.isLoading == false) + #expect(state.estimatedProgress == 0) + #expect(state.lastErrorMessage == "no network") + } + + @Test func commandQueueConsumedOnce() { + let state = makeState() + state.request(.reload) + #expect(state.consumeCommand() == .reload) + #expect(state.consumeCommand() == nil) + } + + @Test func laterCommandReplacesPendingCommand() { + let state = makeState() + state.request(.goBack) + state.request(.goForward) + #expect(state.consumeCommand() == .goForward) + } +} diff --git a/Packages/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/BrowserSurfaceStoreTests.swift b/Packages/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/BrowserSurfaceStoreTests.swift new file mode 100644 index 000000000000..cff691a06775 --- /dev/null +++ b/Packages/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/BrowserSurfaceStoreTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing + +@testable import CmuxMobileBrowser + +/// The store owns at most one browser surface per workspace and survives Mac +/// re-syncs. These guard the open/reveal/close semantics the shell UI relies on. +@MainActor +@Suite struct BrowserSurfaceStoreTests { + private func makeStore() -> BrowserSurfaceStore { + var counter = 0 + return BrowserSurfaceStore( + defaultURL: URL(string: "https://duckduckgo.com/"), + makeSurfaceID: { + counter += 1 + return BrowserSurfaceState.ID(rawValue: "surface-\(counter)") + } + ) + } + + @Test func noBrowserByDefault() { + let store = makeStore() + #expect(store.hasBrowser(for: "ws-1") == false) + #expect(store.activeBrowser(for: "ws-1") == nil) + } + + @Test func openBrowserCreatesSurfaceForWorkspace() { + let store = makeStore() + let surface = store.openBrowser(for: "ws-1") + #expect(store.hasBrowser(for: "ws-1")) + #expect(store.activeBrowser(for: "ws-1") === surface) + #expect(surface.id == .init(rawValue: "surface-1")) + #expect(surface.consumeLoadRequest()?.absoluteString == "https://duckduckgo.com/") + } + + @Test func openBrowserTwiceRevealsSameSurface() { + let store = makeStore() + let first = store.openBrowser(for: "ws-1") + let second = store.openBrowser(for: "ws-1") + // Same instance, so the current page is restored when switching away and + // back (the surface's currentURL is reloaded on re-attach). Full live + // WebKit history persistence across remounts is P2. + #expect(first === second) + } + + @Test func browsersAreScopedPerWorkspace() { + let store = makeStore() + let a = store.openBrowser(for: "ws-1") + let b = store.openBrowser(for: "ws-2") + #expect(a !== b) + #expect(store.activeBrowser(for: "ws-1") === a) + #expect(store.activeBrowser(for: "ws-2") === b) + } + + @Test func closeBrowserClearsOnlyThatWorkspace() { + let store = makeStore() + _ = store.openBrowser(for: "ws-1") + _ = store.openBrowser(for: "ws-2") + store.closeBrowser(for: "ws-1") + #expect(store.hasBrowser(for: "ws-1") == false) + #expect(store.hasBrowser(for: "ws-2")) + } + + @Test func reopenAfterCloseMakesFreshSurface() { + let store = makeStore() + let first = store.openBrowser(for: "ws-1") + store.closeBrowser(for: "ws-1") + let second = store.openBrowser(for: "ws-1") + #expect(first !== second) + #expect(second.id == .init(rawValue: "surface-2")) + } +} diff --git a/Packages/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/BrowserURLResolverTests.swift b/Packages/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/BrowserURLResolverTests.swift new file mode 100644 index 000000000000..d58d264a9f42 --- /dev/null +++ b/Packages/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/BrowserURLResolverTests.swift @@ -0,0 +1,139 @@ +import Foundation +import Testing + +@testable import CmuxMobileBrowser + +/// The address bar maps three input shapes (full URL, bare host, free text) to +/// concrete loads. These guard that mapping, which is where omnibox correctness +/// lives. +@Suite struct BrowserURLResolverTests { + @Test func emptyOrWhitespaceResolvesToNil() { + #expect(BrowserURLResolver.resolve("") == nil) + #expect(BrowserURLResolver.resolve(" ") == nil) + #expect(BrowserURLResolver.resolve("\n\t") == nil) + } + + @Test func fullHTTPSURLLoadsVerbatim() { + let url = BrowserURLResolver.resolve("https://example.com/path?q=1") + #expect(url?.absoluteString == "https://example.com/path?q=1") + } + + @Test func httpSchemeIsPreserved() { + let url = BrowserURLResolver.resolve("http://example.com") + #expect(url?.scheme == "http") + #expect(url?.host == "example.com") + } + + @Test func bareDomainGetsHTTPSScheme() { + let url = BrowserURLResolver.resolve("example.com") + #expect(url?.scheme == "https") + #expect(url?.host == "example.com") + } + + @Test func bareDomainWithPathGetsHTTPSScheme() { + let url = BrowserURLResolver.resolve("example.com/docs/page") + #expect(url?.scheme == "https") + #expect(url?.host == "example.com") + #expect(url?.path == "/docs/page") + } + + @Test func localhostWithPortDefaultsToHTTP() { + // Local dev servers listen on plain HTTP; forcing HTTPS would break the + // common "open my local dev server" cmux workflow. + let url = BrowserURLResolver.resolve("localhost:3000") + #expect(url?.scheme == "http") + #expect(url?.host == "localhost") + #expect(url?.port == 3000) + } + + @Test func loopbackIPDefaultsToHTTP() { + let url = BrowserURLResolver.resolve("127.0.0.1:8080") + #expect(url?.scheme == "http") + #expect(url?.host == "127.0.0.1") + #expect(url?.port == 8080) + } + + @Test func privateLANAddressesDefaultToHTTP() { + for host in ["192.168.1.10", "10.0.0.5", "172.16.0.1"] { + let url = BrowserURLResolver.resolve(host) + #expect(url?.scheme == "http", "expected http for \(host)") + #expect(url?.host == host) + } + } + + @Test func publicIPLikeAddressDefaultsToHTTPS() { + // A non-private dotted-quad is treated as a normal host: HTTPS. + let url = BrowserURLResolver.resolve("8.8.8.8") + #expect(url?.scheme == "https") + #expect(url?.host == "8.8.8.8") + } + + @Test func bareIPv6LoopbackIsBracketedHTTP() { + // `::1` must be recognized as a local host (not a search) and bracketed. + let url = BrowserURLResolver.resolve("::1") + #expect(url?.scheme == "http") + #expect(url?.absoluteString == "http://[::1]") + } + + @Test func bracketedIPv6LoopbackWithPortIsHTTP() { + let url = BrowserURLResolver.resolve("[::1]:3000") + #expect(url?.scheme == "http") + #expect(url?.port == 3000) + } + + @Test func multiWordInputBecomesSearch() { + let url = BrowserURLResolver.resolve("how to write swift") + #expect(url?.host == "duckduckgo.com") + #expect(url?.query?.contains("how") == true) + // Spaces must be percent-encoded, never left raw. + #expect(url?.absoluteString.contains(" ") == false) + } + + @Test func singleWordWithoutDotBecomesSearch() { + let url = BrowserURLResolver.resolve("swift") + #expect(url?.host == "duckduckgo.com") + #expect(url?.query?.contains("swift") == true) + } + + @Test func leadingWhitespaceIsTrimmedBeforeResolving() { + let url = BrowserURLResolver.resolve(" example.com ") + #expect(url?.host == "example.com") + } + + @Test func nonHTTPSchemeFallsBackToSearch() { + // A typed `file:`/`javascript:` must not load as-is; it is treated as a + // query so the address bar can never load a non-web scheme. + let url = BrowserURLResolver.resolve("javascript:alert(1)") + #expect(url?.host == "duckduckgo.com") + } + + @Test func searchEscapesQuerySeparators() { + // `&`, `=`, `+`, `#`, `?` in the typed query must be percent-escaped so + // the search endpoint receives the whole string as one value rather than + // splitting it into extra parameters. + let url = BrowserURLResolver.resolve("AT&T earnings") + #expect(url?.host == "duckduckgo.com") + let raw = url?.absoluteString ?? "" + // Exactly one `&`-free query: the literal `&` is encoded as %26. + #expect(raw.contains("%26")) + // The query component decodes back to the original input. + #expect(url?.query?.contains("q=") == true) + let plusURL = BrowserURLResolver.resolve("C++ tutorial") + #expect(plusURL?.absoluteString.contains("%2B%2B") == true) + } + + @Test func customSearchTemplateIsUsed() { + let url = BrowserURLResolver.resolve( + "hello world", + searchTemplate: "https://search.example/q=%@" + ) + #expect(url?.host == "search.example") + #expect(url?.absoluteString.contains("hello") == true) + } + + @Test func trailingDotTokenIsNotAHost() { + // `foo.` has an empty label after the last dot, so it is a search query. + let url = BrowserURLResolver.resolve("foo.") + #expect(url?.host == "duckduckgo.com") + } +} diff --git a/Packages/CmuxMobileShellUI/Package.swift b/Packages/CmuxMobileShellUI/Package.swift index b9c98f180eeb..64da0b465ffd 100644 --- a/Packages/CmuxMobileShellUI/Package.swift +++ b/Packages/CmuxMobileShellUI/Package.swift @@ -16,6 +16,7 @@ let package = Package( dependencies: [ .package(path: "../CMUXMobileCore"), .package(path: "../CmuxAuthRuntime"), + .package(path: "../CmuxMobileBrowser"), .package(path: "../CmuxMobileCamera"), .package(path: "../CmuxMobileDiagnostics"), .package(path: "../CmuxMobilePairedMac"), @@ -33,6 +34,7 @@ let package = Package( dependencies: [ "CMUXMobileCore", "CmuxAuthRuntime", + "CmuxMobileBrowser", "CmuxMobileCamera", "CmuxMobileDiagnostics", "CmuxMobilePairedMac", diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileAppView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileAppView.swift index 6a1fd0eb3c30..0f7f437ca33d 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileAppView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileAppView.swift @@ -1,3 +1,4 @@ +import CmuxMobileBrowser import CmuxMobileShell import SwiftUI #if os(iOS) @@ -9,6 +10,12 @@ import AppKit public struct CMUXMobileAppView: View { @State private var store: CMUXMobileShellStore + /// Phone-local browser surfaces, owned for the app's lifetime and injected + /// into the environment so the workspace detail view can present a browser + /// pane without threading the store through every intermediate view. Browser + /// state lives here (not in the shell store) because, unlike terminals, it + /// has no Mac-side counterpart and must survive `workspace.updated` re-syncs. + @State private var browserStore: BrowserSurfaceStore #if os(iOS) private let onboardingStore: MobileOnboardingStore #endif @@ -17,27 +24,37 @@ public struct CMUXMobileAppView: View { /// Creates the app view. /// - Parameters: /// - store: The shell store backing the workspace UI. + /// - browserStore: The phone-local browser surface store injected into the + /// environment for workspace detail browser panes. /// - onboardingStore: The first-run onboarding "seen" flag store. Defaults /// to a `.standard`-backed store marked already-seen, so SwiftUI previews /// and ad-hoc construction never present onboarding. public init( store: CMUXMobileShellStore = .preview(), + browserStore: BrowserSurfaceStore = BrowserSurfaceStore(), onboardingStore: MobileOnboardingStore = MobileOnboardingStore(defaults: .standard, forceSeen: true) ) { _store = State(initialValue: store) + _browserStore = State(initialValue: browserStore) self.onboardingStore = onboardingStore } #else - public init(store: CMUXMobileShellStore = .preview()) { + public init( + store: CMUXMobileShellStore = .preview(), + browserStore: BrowserSurfaceStore = BrowserSurfaceStore() + ) { _store = State(initialValue: store) + _browserStore = State(initialValue: browserStore) } #endif public var body: some View { #if os(iOS) CMUXMobileRootView(store: store, onboardingStore: onboardingStore) + .environment(browserStore) #else CMUXMobileRootView(store: store) + .environment(browserStore) #endif } } diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 0f53bb8ed04d..31c121ae91b4 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -1,3 +1,4 @@ +import CmuxMobileBrowser import CmuxMobileDiagnostics import CmuxMobileShell import CmuxMobileShellModel @@ -21,6 +22,10 @@ struct WorkspaceDetailView: View { let reportTerminalViewport: (MobileWorkspacePreview.ID, MobileTerminalPreview.ID, MobileTerminalViewportSize) -> Void let sendTerminalInput: (String) -> Void let safeAreaContext: MobileTerminalSafeAreaContext + /// Phone-local browser surfaces, injected from the app root. When this + /// workspace has an active browser surface the detail view presents a + /// browser pane in place of the terminal; otherwise it shows the terminal. + @Environment(BrowserSurfaceStore.self) private var browserStore #if DEBUG && canImport(UIKit) @State private var isFeedbackComposerPresented = false @State private var feedbackText = "" @@ -31,9 +36,46 @@ struct WorkspaceDetailView: View { workspace.terminals.first { $0.id == store.selectedTerminalID } ?? workspace.terminals.first } + /// The active browser surface for this workspace, when a browser pane is open. + private var activeBrowser: BrowserSurfaceState? { + browserStore.activeBrowser(for: workspace.id.rawValue) + } + var body: some View { + #if os(iOS) + if let browser = activeBrowser { + browserContent(browser) + } else { + detailContent() + } + #else detailContent() + #endif + } + + #if os(iOS) + /// The browser pane shown when this workspace has an active browser surface. + /// It carries its own navigation chrome, so it does not get the terminal's + /// keyboard/safe-area handling. Closing returns to the terminal. + @ViewBuilder + private func browserContent(_ browser: BrowserSurfaceState) -> some View { + MobileBrowserPane( + state: browser, + onClose: { browserStore.closeBrowser(for: workspace.id.rawValue) } + ) + // Key on the surface id so switching/reopening rebuilds the WKWebView. + .id(browser.id.rawValue) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle(browser.title ?? workspace.name) + .mobileTerminalNavigationChrome() + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + newWorkspaceToolbarButton + terminalPickerToolbarButton + } + } } + #endif private func detailContent() -> some View { // `GhosttySurfaceView` owns the bottom accessory bar: it docks the @@ -165,7 +207,9 @@ struct WorkspaceDetailView: View { } label: { Label( terminal.name, - systemImage: terminal.id == selectedTerminal?.id ? "checkmark.circle.fill" : "terminal" + systemImage: terminal.id == selectedTerminal?.id && activeBrowser == nil + ? "checkmark.circle.fill" + : "terminal" ) } .accessibilityIdentifier("MobileTerminalMenuItem-\(terminal.id.rawValue)") @@ -182,6 +226,14 @@ struct WorkspaceDetailView: View { Label(L10n.string("mobile.terminal.new", defaultValue: "New Terminal"), systemImage: "plus") } .accessibilityIdentifier("MobileNewTerminalMenuItem") + + Button(action: openBrowserFromToolbar) { + Label( + L10n.string("mobile.browser.new", defaultValue: "New Browser"), + systemImage: activeBrowser == nil ? "globe" : "checkmark.circle.fill" + ) + } + .accessibilityIdentifier("MobileNewBrowserMenuItem") } #if DEBUG && canImport(UIKit) @@ -282,11 +334,26 @@ struct WorkspaceDetailView: View { private func createTerminalFromToolbar() { dismissTerminalKeyboardForChrome() + // Creating a terminal from the (shared) chrome must surface it. If a + // browser pane is up, close it so `body` leaves the browser branch and + // shows the new terminal instead of staying on the browser. + browserStore.closeBrowser(for: workspace.id.rawValue) createTerminal() } + private func openBrowserFromToolbar() { + dismissTerminalKeyboardForChrome() + // Opens (or reveals the existing) browser pane for this workspace. The + // detail view flips to the browser because `activeBrowser` becomes + // non-nil; the picker shows a check next to "New Browser" while it is up. + browserStore.openBrowser(for: workspace.id.rawValue) + } + private func selectTerminalFromPicker(_ terminalID: MobileTerminalPreview.ID) { dismissTerminalKeyboardForChrome() + // Choosing a terminal returns from the browser pane (if up) to the + // terminal. Closing the browser is enough to flip the detail view back. + browserStore.closeBrowser(for: workspace.id.rawValue) // Switching from the picker is chrome, not a typing intent, so the // newly-selected surface must not grab the keyboard on attach. The // store suppresses the target's autofocus (and is a no-op when it is diff --git a/ios/Config/Info.plist b/ios/Config/Info.plist index 094d437ee342..4543be5a47bb 100644 --- a/ios/Config/Info.plist +++ b/ios/Config/Info.plist @@ -55,6 +55,17 @@ Scan cmux pairing QR codes from your Mac terminal. NSLocalNetworkUsageDescription Connect to your Mac on the local network for cmux mobile pairing and terminal sync. + NSAppTransportSecurity + + + NSAllowsArbitraryLoadsInWebContent + + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 5efeabccfeec..759a2ae73138 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -1191,40 +1191,6 @@ } } }, - "mobile.notifications.disable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Turn Off Agent Notifications" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エージェント通知をオフにする" - } - } - } - }, - "mobile.notifications.enable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Notify Me About Agents" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エージェント通知を受け取る" - } - } - } - }, "mobile.pairing.attachTicketExpired": { "extractionState": "manual", "localizations": { @@ -3673,857 +3639,857 @@ } } }, - "terminal.input_accessory.paste": { + "terminal.input_accessory.showKeyboard": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Paste" + "value": "Show Keyboard" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ペースト" + "value": "キーボードを表示" } } } }, - "mobile.pairing.accountMismatch": { + "terminal.input_accessory.title.alt": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This Mac is signed in to a different cmux account. Sign out and sign back in with the account that owns this Mac." + "value": "Alt" } }, "ja": { "stringUnit": { "state": "translated", - "value": "このMacは別のcmuxアカウントでサインインしています。サインアウトして、このMacを所有するアカウントでサインインし直してください。" + "value": "Alt" } } } }, - "mobile.pairing.appNotRunning": { + "terminal.input_accessory.title.clear": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Your Mac is reachable, but cmux isn't running there (or mobile pairing is off). Open cmux on the Mac, then try again." + "value": "Clear" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macには接続できますが、cmuxが起動していません(またはモバイルのペア設定がオフです)。Macでcmuxを開いてから、もう一度お試しください。" + "value": "クリア" } } } }, - "mobile.pairing.connectTimedOutFormat": { + "terminal.input_accessory.title.control": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No response from %@:%d. Your Mac may be asleep or off Tailscale. Make sure it's awake and on the same Tailscale network." + "value": "Ctrl" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@:%d から応答がありません。Macがスリープしているか、Tailscaleに接続されていない可能性があります。Macがスリープしておらず、同じTailscaleネットワーク上にあることを確認してください。" + "value": "Ctrl" } } } }, - "mobile.pairing.dnsFailedFormat": { + "terminal.input_accessory.title.end": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Couldn't resolve %@. Check that Tailscale is connected on both devices." + "value": "End" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@ を解決できませんでした。両方の端末でTailscaleが接続されていることを確認してください。" + "value": "End" } } } }, - "mobile.pairing.fail.offline": { + "terminal.input_accessory.title.escape": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This device looks offline. Connect to Wi-Fi or cellular, then try again." + "value": "Esc" } }, "ja": { "stringUnit": { "state": "translated", - "value": "この端末はオフラインのようです。Wi-Fiまたはモバイルデータに接続してから、もう一度お試しください。" + "value": "Esc" } } } }, - "mobile.pairing.guidance.localNetwork": { + "terminal.input_accessory.title.home": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Settings > cmux > Local Network, then try again." + "value": "Home" } }, "ja": { "stringUnit": { "state": "translated", - "value": "設定 > cmux > ローカルネットワーク を開いてから、もう一度お試しください。" + "value": "Home" } } } }, - "mobile.pairing.guidance.openMacApp": { + "terminal.input_accessory.title.tab": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open cmux on your Mac, then scan the QR or link from it again." + "value": "Tab" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macでcmuxを開いてから、そのQRコードまたはリンクをもう一度スキャンしてください。" + "value": "Tab" } } } }, - "mobile.pairing.guidance.reachability": { + "terminal.input_accessory.zoom_in": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Check that this phone and your Mac are on the same Wi-Fi or both running Tailscale, that the Mac is awake, and that cmux is open on it." + "value": "Zoom In" } }, "ja": { "stringUnit": { "state": "translated", - "value": "このスマートフォンとMacが同じWi-Fi上にあるか、両方でTailscaleが動作していること、Macがスリープしていないこと、Macでcmuxが開いていることを確認してください。" + "value": "拡大" } } } }, - "mobile.pairing.guidance.rescanFresh": { + "terminal.input_accessory.zoom_out": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open the pairing window on your Mac and scan a fresh QR or link." + "value": "Zoom Out" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macでペアリングウィンドウを開き、新しいQRコードまたはリンクをスキャンしてください。" + "value": "縮小" } } } }, - "mobile.pairing.guidance.sameAccount": { + "terminal.shortcut.name.ctrlL": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Both devices must be signed in to the same cmux account." + "value": "Clear (Control-L)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "両方の端末が同じcmuxアカウントでサインインしている必要があります。" + "value": "クリア (Control-L)" } } } }, - "mobile.pairing.hostUnreachableFormat": { + "terminal.shortcut.name.control": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Can't reach %@:%d. Make sure your Mac is awake and on the same Tailscale network as this device." + "value": "Control" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@:%d に接続できません。Macがスリープしておらず、この端末と同じTailscaleネットワーク上にあることを確認してください。" + "value": "Control" } } } }, - "mobile.pairing.localNetworkPermission": { + "terminal.shortcut.name.alternate": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "iOS blocked the connection. Allow cmux to use the Local Network in iOS Settings, then try again." + "value": "Option" } }, "ja": { "stringUnit": { "state": "translated", - "value": "iOSが接続をブロックしました。iOSの設定でcmuxにローカルネットワークの使用を許可してから、もう一度お試しください。" + "value": "Option" } } } }, - "mobile.onboarding.skip": { + "terminal.shortcut.name.command": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Skip" + "value": "Command" } }, "ja": { "stringUnit": { "state": "translated", - "value": "スキップ" + "value": "Command" } } } }, - "mobile.onboarding.next": { + "terminal.zoom.current_size": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Next" + "value": "%lld pt" } }, "ja": { "stringUnit": { "state": "translated", - "value": "次へ" + "value": "%lld pt" } } } }, - "mobile.onboarding.getStarted": { + "terminal.zoom.reset_to_default": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Get started" + "value": "Reset to default" } }, "ja": { "stringUnit": { "state": "translated", - "value": "始める" + "value": "デフォルトに戻す" } } } }, - "mobile.onboarding.whatTitle": { + "terminal.zoom.restore_built_in": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Your Mac's terminals, on your phone" + "value": "Restore built-in" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macのターミナルを、iPhoneで" + "value": "標準に戻す" } } } }, - "mobile.onboarding.whatBody": { + "terminal.zoom.set_as_default": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "cmux runs your terminals and AI coding agents on your Mac. This app lets you watch them, type, and get notified when an agent needs you, right from your phone." + "value": "Set as default" } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmuxはMac上でターミナルとAIコーディングエージェントを動かします。このアプリを使えば、iPhoneからその様子を見たり、入力したり、エージェントから呼ばれたときに通知を受け取れます。" + "value": "デフォルトに設定" } } } }, - "mobile.onboarding.connectTitle": { + "mobile.notifications.enable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "A private link to your Mac" + "value": "Notify Me About Agents" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macへのプライベートな接続" + "value": "エージェント通知を受け取る" } } } }, - "mobile.onboarding.connectBody": { + "mobile.notifications.disable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Your phone has to reach your Mac over the network. The simplest private path is Tailscale: both devices join the same tailnet and connect directly, with no cloud relay. On the same Wi-Fi you can instead type the Mac's local address by hand, but that link is unencrypted, so Tailscale is recommended." + "value": "Turn Off Agent Notifications" } }, "ja": { "stringUnit": { "state": "translated", - "value": "iPhoneはネットワーク経由でMacに届く必要があります。最もシンプルでプライベートな方法はTailscaleです。両方の端末を同じtailnetに参加させると、クラウド中継なしで直接つながります。同じWi-Fiなら、Macのローカルアドレスを手入力して接続することもできますが、その接続は暗号化されないため、Tailscaleをおすすめします。" + "value": "エージェント通知をオフにする" } } } }, - "mobile.onboarding.tailscaleLink": { + "terminal.input_accessory.paste": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Set up Tailscale on the Mac" + "value": "Paste" } }, "ja": { "stringUnit": { "state": "translated", - "value": "MacにTailscaleを設定" + "value": "ペースト" } } } }, - "mobile.onboarding.pairTitle": { + "mobile.pairing.accountMismatch": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Pair your Mac" + "value": "This Mac is signed in to a different cmux account. Sign out and sign back in with the account that owns this Mac." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macとペアリング" + "value": "このMacは別のcmuxアカウントでサインインしています。サインアウトして、このMacを所有するアカウントでサインインし直してください。" } } } }, - "mobile.onboarding.pairBody": { + "mobile.pairing.appNotRunning": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Make sure cmux on your Mac is signed in to the same account, then scan the pairing QR code it shows (or enter its address by hand). You only do this once per Mac." + "value": "Your Mac is reachable, but cmux isn't running there (or mobile pairing is off). Open cmux on the Mac, then try again." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Mac版cmuxが同じアカウントでサインインしていることを確認し、表示されるペアリングQRコードを読み取ってください(またはアドレスを手入力します)。これはMacごとに一度だけ行います。" + "value": "Macには接続できますが、cmuxが起動していません(またはモバイルのペア設定がオフです)。Macでcmuxを開いてから、もう一度お試しください。" } } } }, - "mobile.settings.howPairingWorks": { + "mobile.pairing.connectTimedOutFormat": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "How Pairing Works" + "value": "No response from %@:%d. Your Mac may be asleep or off Tailscale. Make sure it's awake and on the same Tailscale network." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ペアリングの仕組み" + "value": "%@:%d から応答がありません。Macがスリープしているか、Tailscaleに接続されていない可能性があります。Macがスリープしておらず、同じTailscaleネットワーク上にあることを確認してください。" } } } }, - "mobile.onboarding.tailscaleTitle": { + "mobile.pairing.dnsFailedFormat": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Put both devices on Tailscale" + "value": "Couldn't resolve %@. Check that Tailscale is connected on both devices." } }, "ja": { "stringUnit": { "state": "translated", - "value": "両方の端末をTailscaleに入れる" + "value": "%@ を解決できませんでした。両方の端末でTailscaleが接続されていることを確認してください。" } } } }, - "mobile.onboarding.tailscaleBody": { + "mobile.pairing.fail.offline": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Tailscale is a free app that gives your devices a private network. Do this once, on both the phone and the Mac." + "value": "This device looks offline. Connect to Wi-Fi or cellular, then try again." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Tailscaleは、端末同士をプライベートなネットワークでつなぐ無料アプリです。iPhoneとMacの両方で一度だけ設定してください。" + "value": "この端末はオフラインのようです。Wi-Fiまたはモバイルデータに接続してから、もう一度お試しください。" } } } }, - "mobile.onboarding.tailscaleStep1": { + "mobile.pairing.guidance.localNetwork": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Install Tailscale on this phone and on your Mac." + "value": "Settings > cmux > Local Network, then try again." } }, "ja": { "stringUnit": { "state": "translated", - "value": "この端末とMacの両方にTailscaleをインストールします。" + "value": "設定 > cmux > ローカルネットワーク を開いてから、もう一度お試しください。" } } } }, - "mobile.onboarding.tailscaleStep2": { + "mobile.pairing.guidance.openMacApp": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sign both in to the same Tailscale account so they share one tailnet." + "value": "Open cmux on your Mac, then scan the QR or link from it again." } }, "ja": { "stringUnit": { "state": "translated", - "value": "両方を同じTailscaleアカウントでサインインし、同じtailnetを共有させます。" + "value": "Macでcmuxを開いてから、そのQRコードまたはリンクをもう一度スキャンしてください。" } } } }, - "mobile.onboarding.tailscaleStep3": { + "mobile.pairing.guidance.reachability": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Keep both signed in to the same cmux account too. Pairing checks that they match." + "value": "Check that this phone and your Mac are on the same Wi-Fi or both running Tailscale, that the Mac is awake, and that cmux is open on it." } }, "ja": { "stringUnit": { "state": "translated", - "value": "両方を同じcmuxアカウントでもサインインしておきます。ペアリング時に一致を確認します。" + "value": "このスマートフォンとMacが同じWi-Fi上にあるか、両方でTailscaleが動作していること、Macがスリープしていないこと、Macでcmuxが開いていることを確認してください。" } } } }, - "mobile.onboarding.tailscaleAppStoreLink": { + "mobile.pairing.guidance.rescanFresh": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Get Tailscale for iPhone" + "value": "Open the pairing window on your Mac and scan a fresh QR or link." } }, "ja": { "stringUnit": { "state": "translated", - "value": "iPhone版Tailscaleを入手" + "value": "Macでペアリングウィンドウを開き、新しいQRコードまたはリンクをスキャンしてください。" } } } }, - "mobile.onboarding.troubleConnecting": { + "mobile.pairing.guidance.sameAccount": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Trouble connecting?" + "value": "Both devices must be signed in to the same cmux account." } }, "ja": { "stringUnit": { "state": "translated", - "value": "接続できないときは" + "value": "両方の端末が同じcmuxアカウントでサインインしている必要があります。" } } } }, - "mobile.setupHelp.title": { + "mobile.pairing.hostUnreachableFormat": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Set up your Mac" + "value": "Can't reach %@:%d. Make sure your Mac is awake and on the same Tailscale network as this device." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macをセットアップ" + "value": "%@:%d に接続できません。Macがスリープしておらず、この端末と同じTailscaleネットワーク上にあることを確認してください。" } } } }, - "mobile.setupHelp.intro": { + "mobile.pairing.localNetworkPermission": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "To see your Mac's terminals here, four things have to line up. The step you are on is marked below." + "value": "iOS blocked the connection. Allow cmux to use the Local Network in iOS Settings, then try again." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ここでMacのターミナルを表示するには、4つの条件がそろう必要があります。今いるステップは下に印が付いています。" + "value": "iOSが接続をブロックしました。iOSの設定でcmuxにローカルネットワークの使用を許可してから、もう一度お試しください。" } } } }, - "mobile.setupHelp.youAreHere": { + "mobile.onboarding.skip": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "You are here" + "value": "Skip" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ここです" + "value": "スキップ" } } } }, - "mobile.setupHelp.signInTitle": { + "mobile.onboarding.next": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sign in" + "value": "Next" } }, "ja": { "stringUnit": { "state": "translated", - "value": "サインイン" + "value": "次へ" } } } }, - "mobile.setupHelp.signInBody": { + "mobile.onboarding.getStarted": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sign in to cmux on this phone with the same account your Mac uses. Without that, there is nothing to connect to." + "value": "Get started" } }, "ja": { "stringUnit": { "state": "translated", - "value": "この端末で、Macと同じアカウントを使ってcmuxにサインインします。サインインしないと、接続先がありません。" + "value": "始める" } } } }, - "mobile.setupHelp.macAppTitle": { + "mobile.onboarding.whatTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Run cmux on your Mac" + "value": "Your Mac's terminals, on your phone" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macでcmuxを起動" + "value": "Macのターミナルを、iPhoneで" } } } }, - "mobile.setupHelp.macAppBody": { + "mobile.onboarding.whatBody": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Install the cmux Mac app and leave it running, signed in to the same account. The phone pairs to a running Mac, so a quit or never-installed app is the most common reason pairing does nothing." + "value": "cmux runs your terminals and AI coding agents on your Mac. This app lets you watch them, type, and get notified when an agent needs you, right from your phone." } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmuxのMacアプリをインストールし、同じアカウントでサインインしたまま起動しておきます。iPhoneは起動中のMacとペアリングするため、アプリが終了している、または未インストールであることが、ペアリングが進まない最も多い原因です。" + "value": "cmuxはMac上でターミナルとAIコーディングエージェントを動かします。このアプリを使えば、iPhoneからその様子を見たり、入力したり、エージェントから呼ばれたときに通知を受け取れます。" } } } }, - "mobile.setupHelp.macAppLink": { + "mobile.onboarding.connectTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Download cmux for Mac" + "value": "A private link to your Mac" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Mac版cmuxをダウンロード" + "value": "Macへのプライベートな接続" } } } }, - "mobile.setupHelp.unreachableTitle": { + "mobile.onboarding.connectBody": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Wake the Mac" + "value": "Your phone has to reach your Mac over the network. The simplest private path is Tailscale: both devices join the same tailnet and connect directly, with no cloud relay. On the same Wi-Fi you can instead type the Mac's local address by hand, but that link is unencrypted, so Tailscale is recommended." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macを起こす" + "value": "iPhoneはネットワーク経由でMacに届く必要があります。最もシンプルでプライベートな方法はTailscaleです。両方の端末を同じtailnetに参加させると、クラウド中継なしで直接つながります。同じWi-Fiなら、Macのローカルアドレスを手入力して接続することもできますが、その接続は暗号化されないため、Tailscaleをおすすめします。" } } } }, - "mobile.setupHelp.unreachableBody": { + "mobile.onboarding.tailscaleLink": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "You have paired this Mac before but it is not reachable now. Wake it, make sure cmux is running, and confirm both devices are on the same tailnet or Wi-Fi. Then reconnect." + "value": "Set up Tailscale on the Mac" } }, "ja": { "stringUnit": { "state": "translated", - "value": "このMacは以前ペアリング済みですが、今は届きません。Macを起こし、cmuxが起動していることを確認し、両方の端末が同じtailnetまたはWi-Fiにあることを確かめてから再接続してください。" + "value": "MacにTailscaleを設定" } } } }, - "mobile.setupHelp.mismatchTitle": { + "mobile.onboarding.pairTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Match the account" + "value": "Pair your Mac" } }, "ja": { "stringUnit": { "state": "translated", - "value": "アカウントを合わせる" + "value": "Macとペアリング" } } } }, - "mobile.setupHelp.mismatchBody": { + "mobile.onboarding.pairBody": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "If the Mac rejects this device's sign-in, the two are on different cmux accounts or this device's session is stale. Sign this phone in to the Mac's account (or sign the Mac in to this one), then pair again." + "value": "Make sure cmux on your Mac is signed in to the same account, then scan the pairing QR code it shows (or enter its address by hand). You only do this once per Mac." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macがこの端末のサインインを拒否する場合、両者が別々のcmuxアカウントになっているか、この端末のセッションが古くなっています。この端末をMacのアカウントでサインインし(またはMacをこの端末のアカウントでサインインし)、もう一度ペアリングしてください。" + "value": "Mac版cmuxが同じアカウントでサインインしていることを確認し、表示されるペアリングQRコードを読み取ってください(またはアドレスを手入力します)。これはMacごとに一度だけ行います。" } } } }, - "mobile.setupHelp.networkTitle": { + "mobile.settings.howPairingWorks": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Get them on the same network" + "value": "How Pairing Works" } }, "ja": { "stringUnit": { "state": "translated", - "value": "同じネットワークに入れる" + "value": "ペアリングの仕組み" } } } }, - "mobile.setupHelp.networkBody": { + "mobile.onboarding.tailscaleTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Scanning the Mac's QR code needs Tailscale: the Mac only shows a code when it has a Tailscale address your phone can reach. Install Tailscale on both, sign both in to the same tailnet, and the code appears." + "value": "Put both devices on Tailscale" } }, "ja": { "stringUnit": { "state": "translated", - "value": "MacのQRコードをスキャンするにはTailscaleが必要です。Macは、iPhoneから届くTailscaleアドレスを持っているときだけコードを表示します。両方にTailscaleをインストールし、同じtailnetでサインインすると、コードが表示されます。" + "value": "両方の端末をTailscaleに入れる" } } } }, - "mobile.setupHelp.tailscaleAppStore": { + "mobile.onboarding.tailscaleBody": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Get Tailscale for iPhone" + "value": "Tailscale is a free app that gives your devices a private network. Do this once, on both the phone and the Mac." } }, "ja": { "stringUnit": { "state": "translated", - "value": "iPhone版Tailscaleを入手" + "value": "Tailscaleは、端末同士をプライベートなネットワークでつなぐ無料アプリです。iPhoneとMacの両方で一度だけ設定してください。" } } } }, - "mobile.setupHelp.tailscaleMac": { + "mobile.onboarding.tailscaleStep1": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Set up Tailscale on the Mac" + "value": "Install Tailscale on this phone and on your Mac." } }, "ja": { "stringUnit": { "state": "translated", - "value": "MacにTailscaleを設定" + "value": "この端末とMacの両方にTailscaleをインストールします。" } } } }, - "mobile.setupHelp.lanBody": { + "mobile.onboarding.tailscaleStep2": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No Tailscale? On the same Wi-Fi you can still connect by typing the Mac's local address and port by hand in Add device. That link is unencrypted, so only use it on a network you trust." + "value": "Sign both in to the same Tailscale account so they share one tailnet." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Tailscaleがない場合でも、同じWi-Fiなら、「デバイスを追加」でMacのローカルアドレスとポートを手入力して接続できます。その接続は暗号化されないため、信頼できるネットワークでのみ使用してください。" + "value": "両方を同じTailscaleアカウントでサインインし、同じtailnetを共有させます。" } } } }, - "mobile.setupHelp.sameAccountFooter": { + "mobile.onboarding.tailscaleStep3": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The Mac and this phone must be signed in to the same cmux account, and on the same tailnet (or the same Wi-Fi for a manual local connection)." + "value": "Keep both signed in to the same cmux account too. Pairing checks that they match." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macとこの端末は、同じcmuxアカウントでサインインし、同じtailnet(手動でローカル接続する場合は同じWi-Fi)にある必要があります。" + "value": "両方を同じcmuxアカウントでもサインインしておきます。ペアリング時に一致を確認します。" } } } }, - "mobile.settings.setUpYourMac": { + "mobile.onboarding.tailscaleAppStoreLink": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Set up your Mac" + "value": "Get Tailscale for iPhone" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macをセットアップ" + "value": "iPhone版Tailscaleを入手" } } } }, - "mobile.devices.setupHelp": { + "mobile.onboarding.troubleConnecting": { "extractionState": "manual", "localizations": { "en": { @@ -4540,325 +4506,478 @@ } } }, - "mobile.setupHelp.introReference": { + "mobile.setupHelp.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "To see your Mac's terminals here, four things have to line up." + "value": "Set up your Mac" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ここでMacのターミナルを表示するには、4つの条件がそろう必要があります。" + "value": "Macをセットアップ" } } } }, - "terminal.input_accessory.showKeyboard": { + "mobile.setupHelp.intro": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Show Keyboard" + "value": "To see your Mac's terminals here, four things have to line up. The step you are on is marked below." } }, "ja": { "stringUnit": { "state": "translated", - "value": "キーボードを表示" + "value": "ここでMacのターミナルを表示するには、4つの条件がそろう必要があります。今いるステップは下に印が付いています。" } } } }, - "terminal.input_accessory.title.alt": { + "mobile.setupHelp.youAreHere": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Alt" + "value": "You are here" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Alt" + "value": "ここです" } } } }, - "terminal.input_accessory.title.clear": { + "mobile.setupHelp.signInTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Clear" + "value": "Sign in" } }, "ja": { "stringUnit": { "state": "translated", - "value": "クリア" + "value": "サインイン" } } } }, - "terminal.input_accessory.title.control": { + "mobile.setupHelp.signInBody": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Ctrl" + "value": "Sign in to cmux on this phone with the same account your Mac uses. Without that, there is nothing to connect to." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Ctrl" + "value": "この端末で、Macと同じアカウントを使ってcmuxにサインインします。サインインしないと、接続先がありません。" } } } }, - "terminal.input_accessory.title.end": { + "mobile.setupHelp.macAppTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "End" + "value": "Run cmux on your Mac" } }, "ja": { "stringUnit": { "state": "translated", - "value": "End" + "value": "Macでcmuxを起動" } } } }, - "terminal.input_accessory.title.escape": { + "mobile.setupHelp.macAppBody": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Esc" + "value": "Install the cmux Mac app and leave it running, signed in to the same account. The phone pairs to a running Mac, so a quit or never-installed app is the most common reason pairing does nothing." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Esc" + "value": "cmuxのMacアプリをインストールし、同じアカウントでサインインしたまま起動しておきます。iPhoneは起動中のMacとペアリングするため、アプリが終了している、または未インストールであることが、ペアリングが進まない最も多い原因です。" } } } }, - "terminal.input_accessory.title.home": { + "mobile.setupHelp.macAppLink": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Home" + "value": "Download cmux for Mac" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Home" + "value": "Mac版cmuxをダウンロード" } } } }, - "terminal.input_accessory.title.tab": { + "mobile.setupHelp.unreachableTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Tab" + "value": "Wake the Mac" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Tab" + "value": "Macを起こす" } } } }, - "terminal.input_accessory.zoom_in": { + "mobile.setupHelp.unreachableBody": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Zoom In" + "value": "You have paired this Mac before but it is not reachable now. Wake it, make sure cmux is running, and confirm both devices are on the same tailnet or Wi-Fi. Then reconnect." } }, "ja": { "stringUnit": { "state": "translated", - "value": "拡大" + "value": "このMacは以前ペアリング済みですが、今は届きません。Macを起こし、cmuxが起動していることを確認し、両方の端末が同じtailnetまたはWi-Fiにあることを確かめてから再接続してください。" } } } }, - "terminal.input_accessory.zoom_out": { + "mobile.setupHelp.mismatchTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Zoom Out" + "value": "Match the account" } }, "ja": { "stringUnit": { "state": "translated", - "value": "縮小" + "value": "アカウントを合わせる" } } } }, - "terminal.shortcut.name.alternate": { + "mobile.setupHelp.mismatchBody": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Option" + "value": "If the Mac rejects this device's sign-in, the two are on different cmux accounts or this device's session is stale. Sign this phone in to the Mac's account (or sign the Mac in to this one), then pair again." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Option" + "value": "Macがこの端末のサインインを拒否する場合、両者が別々のcmuxアカウントになっているか、この端末のセッションが古くなっています。この端末をMacのアカウントでサインインし(またはMacをこの端末のアカウントでサインインし)、もう一度ペアリングしてください。" } } } }, - "terminal.shortcut.name.command": { + "mobile.setupHelp.networkTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Command" + "value": "Get them on the same network" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Command" + "value": "同じネットワークに入れる" } } } }, - "terminal.shortcut.name.control": { + "mobile.setupHelp.networkBody": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Control" + "value": "Scanning the Mac's QR code needs Tailscale: the Mac only shows a code when it has a Tailscale address your phone can reach. Install Tailscale on both, sign both in to the same tailnet, and the code appears." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Control" + "value": "MacのQRコードをスキャンするにはTailscaleが必要です。Macは、iPhoneから届くTailscaleアドレスを持っているときだけコードを表示します。両方にTailscaleをインストールし、同じtailnetでサインインすると、コードが表示されます。" } } } }, - "terminal.shortcut.name.ctrlL": { + "mobile.setupHelp.tailscaleAppStore": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Clear (Control-L)" + "value": "Get Tailscale for iPhone" } }, "ja": { "stringUnit": { "state": "translated", - "value": "クリア (Control-L)" + "value": "iPhone版Tailscaleを入手" } } } }, - "terminal.zoom.current_size": { + "mobile.setupHelp.tailscaleMac": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "%lld pt" + "value": "Set up Tailscale on the Mac" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%lld pt" + "value": "MacにTailscaleを設定" } } } }, - "terminal.zoom.reset_to_default": { + "mobile.setupHelp.lanBody": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Reset to default" + "value": "No Tailscale? On the same Wi-Fi you can still connect by typing the Mac's local address and port by hand in Add device. That link is unencrypted, so only use it on a network you trust." } }, "ja": { "stringUnit": { "state": "translated", - "value": "デフォルトに戻す" + "value": "Tailscaleがない場合でも、同じWi-Fiなら、「デバイスを追加」でMacのローカルアドレスとポートを手入力して接続できます。その接続は暗号化されないため、信頼できるネットワークでのみ使用してください。" } } } }, - "terminal.zoom.restore_built_in": { + "mobile.setupHelp.sameAccountFooter": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Restore built-in" + "value": "The Mac and this phone must be signed in to the same cmux account, and on the same tailnet (or the same Wi-Fi for a manual local connection)." } }, "ja": { "stringUnit": { "state": "translated", - "value": "標準に戻す" + "value": "Macとこの端末は、同じcmuxアカウントでサインインし、同じtailnet(手動でローカル接続する場合は同じWi-Fi)にある必要があります。" } } } }, - "terminal.zoom.set_as_default": { + "mobile.settings.setUpYourMac": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Set as default" + "value": "Set up your Mac" } }, "ja": { "stringUnit": { "state": "translated", - "value": "デフォルトに設定" + "value": "Macをセットアップ" + } + } + } + }, + "mobile.devices.setupHelp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Trouble connecting?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続できないときは" + } + } + } + }, + "mobile.setupHelp.introReference": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "To see your Mac's terminals here, four things have to line up." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ここでMacのターミナルを表示するには、4つの条件がそろう必要があります。" + } + } + } + }, + "mobile.browser.addressPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search or enter address" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索またはアドレスを入力" + } + } + } + }, + "mobile.browser.back": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Back" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "戻る" + } + } + } + }, + "mobile.browser.close": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザを閉じる" + } + } + } + }, + "mobile.browser.forward": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Forward" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "進む" + } + } + } + }, + "mobile.browser.new": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規ブラウザ" + } + } + } + }, + "mobile.browser.reload": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再読み込み" + } + } + } + }, + "mobile.browser.stop": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "停止" } } } diff --git a/ios/cmuxPackage/Package.swift b/ios/cmuxPackage/Package.swift index d0d5bb1d1175..82a7a20dc90c 100644 --- a/ios/cmuxPackage/Package.swift +++ b/ios/cmuxPackage/Package.swift @@ -25,6 +25,7 @@ let package = Package( .package(path: "../../Packages/CmuxAuthRuntime"), .package(path: "../../Packages/CMUXMobileCore"), .package(path: "../../Packages/CmuxMobileAnalytics"), + .package(path: "../../Packages/CmuxMobileBrowser"), .package(path: "../../Packages/CmuxMobileCamera"), .package(path: "../../Packages/CmuxMobileDiagnostics"), .package(path: "../../Packages/CmuxMobilePairedMac"), @@ -47,6 +48,7 @@ let package = Package( "CmuxAuthRuntime", "CMUXMobileCore", "CmuxMobileAnalytics", + "CmuxMobileBrowser", "CmuxMobileCamera", "CmuxMobileDiagnostics", "CmuxMobilePairedMac", @@ -74,6 +76,7 @@ let package = Package( "CmuxAuthRuntime", "CMUXMobileCore", "CmuxMobileAnalytics", + "CmuxMobileBrowser", "CmuxMobileCamera", "CmuxMobileDiagnostics", "CmuxMobilePairedMac",