Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9991f49
iOS: hierarchical device tree (device → tags → workspaces) over the d…
lawrencecchen Jun 8, 2026
c0b9d04
Device tree: fix account-switch race + multi-tag wrong-tag workspaces…
lawrencecchen Jun 8, 2026
4b36c26
Device tree: roll back failed registry connect + paired-Mac fallback …
lawrencecchen Jun 8, 2026
34d4f9e
Device tree: roll back to the active Mac even on a same-device tag sw…
lawrencecchen Jun 8, 2026
ab9bf3d
Device tree: clear team-scoped registry data on auth rejection (autor…
lawrencecchen Jun 8, 2026
a7c8039
Device tree: present from workspace list (single sheet) + recognize m…
lawrencecchen Jun 8, 2026
dd4ac64
iOS: mobile browser panes P1 (WKWebView surface)
lawrencecchen Jun 8, 2026
24ae20a
Merge branch 'main' into feat-ios-device-tree
lawrencecchen Jun 10, 2026
b082ee1
Merge remote-tracking branch 'origin/main' into feat-ios-mobile-browser
lawrencecchen Jun 10, 2026
4afe97d
Make BrowserURLResolver an uninstantiable struct per package conventions
lawrencecchen Jun 10, 2026
427b389
chore: refresh MobileShellComposite file length budget for device-tre…
lawrencecchen Jun 10, 2026
4177d5a
Merge remote-tracking branch 'origin/main' into feat-ios-device-tree
lawrencecchen Jun 10, 2026
664de68
Merge remote-tracking branch 'origin/main' into feat-ios-mobile-browser
lawrencecchen Jun 10, 2026
cb188c5
Guard authRejected registry blanking on the requesting user still bei…
lawrencecchen Jun 10, 2026
713768d
Merge remote-tracking branch 'origin/main' into feat-ios-mobile-browser
lawrencecchen Jun 10, 2026
59acfbe
Merge remote-tracking branch 'origin/main' into feat-ios-device-tree
lawrencecchen Jun 10, 2026
fba6d25
Merge remote-tracking branch 'origin/feat-ios-device-tree' into feat-…
lawrencecchen Jun 10, 2026
a0d6660
chore: refresh file-length budget for the authRejected guard growth
lawrencecchen Jun 10, 2026
3b9d6f5
Merge remote-tracking branch 'origin/feat-ios-device-tree' into feat-…
lawrencecchen Jun 10, 2026
3efcb18
Merge remote-tracking branch 'origin/main' into feat-ios-mobile-browser
lawrencecchen Jun 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions Packages/CmuxMobileBrowser/Package.swift
Original file line number Diff line number Diff line change
@@ -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"),
]
),
]
)
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +21 to +23

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Mutable identifier type used as a dictionary key. BrowserSurfaceStore keys surfacesByWorkspace by workspace.id.rawValue (a String), and BrowserSurfaceState.id is itself stored as a let constant — but ID.rawValue being var means callers can mutate an ID value after it has been hashed into a Set or used as a Dictionary key, producing silent lookup failures. An identifier type should be immutable at the value level.

Suggested change
public struct ID: RawRepresentable, Hashable, Sendable {
/// The backing identifier string.
public var rawValue: String
public struct ID: RawRepresentable, Hashable, Sendable {
/// The backing identifier string.
public let rawValue: String

Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)


/// 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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading