Repository navigation
iOS: mobile browser panes P1 (WKWebView surface) #5652
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 c0b9d04
Device tree: fix account-switch race + multi-tag wrong-tag workspaces…
lawrencecchen 4b36c26
Device tree: roll back failed registry connect + paired-Mac fallback …
lawrencecchen 34d4f9e
Device tree: roll back to the active Mac even on a same-device tag sw…
lawrencecchen ab9bf3d
Device tree: clear team-scoped registry data on auth rejection (autor…
lawrencecchen a7c8039
Device tree: present from workspace list (single sheet) + recognize m…
lawrencecchen dd4ac64
iOS: mobile browser panes P1 (WKWebView surface)
lawrencecchen 24ae20a
Merge branch 'main' into feat-ios-device-tree
lawrencecchen b082ee1
Merge remote-tracking branch 'origin/main' into feat-ios-mobile-browser
lawrencecchen 4afe97d
Make BrowserURLResolver an uninstantiable struct per package conventions
lawrencecchen 427b389
chore: refresh MobileShellComposite file length budget for device-tre…
lawrencecchen 4177d5a
Merge remote-tracking branch 'origin/main' into feat-ios-device-tree
lawrencecchen 664de68
Merge remote-tracking branch 'origin/main' into feat-ios-mobile-browser
lawrencecchen cb188c5
Guard authRejected registry blanking on the requesting user still bei…
lawrencecchen 713768d
Merge remote-tracking branch 'origin/main' into feat-ios-mobile-browser
lawrencecchen 59acfbe
Merge remote-tracking branch 'origin/main' into feat-ios-device-tree
lawrencecchen fba6d25
Merge remote-tracking branch 'origin/feat-ios-device-tree' into feat-…
lawrencecchen a0d6660
chore: refresh file-length budget for the authRejected guard growth
lawrencecchen 3b9d6f5
Merge remote-tracking branch 'origin/feat-ios-device-tree' into feat-…
lawrencecchen 3efcb18
Merge remote-tracking branch 'origin/main' into feat-ios-mobile-browser
lawrencecchen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"), | ||
| ] | ||
| ), | ||
| ] | ||
| ) |
193 changes: 193 additions & 0 deletions
193
Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserSurfaceState.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
| /// 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 | ||
| } | ||
| } | ||
92 changes: 92 additions & 0 deletions
92
Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserSurfaceStore.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BrowserSurfaceStorekeyssurfacesByWorkspacebyworkspace.id.rawValue(a String), andBrowserSurfaceState.idis itself stored as aletconstant — butID.rawValuebeingvarmeans callers can mutate anIDvalue after it has been hashed into aSetor used as aDictionarykey, producing silent lookup failures. An identifier type should be immutable at the value level.Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)