diff --git a/.gitignore b/.gitignore
index 3bcd959a..a1c5bd38 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,9 @@ tmp-*.mjs
*.png
!apps/web/public/icons/**/*.png
!apps/browser-extension/public/icons/*.png
+!apps/browser-extension/safari/**/*.png
+apps/browser-extension/safari/**/xcuserdata/
+apps/browser-extension/safari/**/build/
*.jpg
*.jpeg
*.gif
diff --git a/apps/browser-extension/README.md b/apps/browser-extension/README.md
index 4a48f1a2..df8b06a3 100644
--- a/apps/browser-extension/README.md
+++ b/apps/browser-extension/README.md
@@ -60,6 +60,66 @@ page and in HTTP/HTTPS frames, including cross-origin frames where Chrome
permits extension injection. Frames Chrome refuses to inject into and closed
shadow roots are not inspected.
+## Safari (iPad / iPhone / Mac)
+
+The Safari version reuses the same Dispatch pairing, agents, and submission
+system with a mobile-first flow: the extension popup handles connect and
+"Select element"; picking (tap + parent/child refine), the comment, and Send
+all happen in a transient in-page overlay that removes itself when done. The
+page only ignores taps while you are aiming — scrolling always works, and the
+comment card releases the page entirely.
+
+Build the web extension bundle:
+
+```sh
+pnpm --filter @dispatch/browser-extension build:safari
+```
+
+The output lands in `apps/browser-extension/dist/safari/unpacked`, which the
+checked-in Xcode project references directly — rebuilding the bundle is enough
+for the next Xcode build to pick it up.
+
+### Run on the iPad simulator
+
+Open `apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback.xcodeproj`,
+select an iPad simulator, and Run. In the simulator: Settings → Apps →
+Safari → Extensions → Dispatch Browser Feedback → enable. Then open Safari,
+tap the extension (puzzle) button in the address bar, and open Dispatch
+feedback.
+
+### Distribute through TestFlight
+
+The project is preconfigured with automatic signing for team `ML8BQ6D727`
+(the same team the Mac release binaries sign with) and bundle IDs
+`dev.bradharris.dispatch.feedback` / `dev.bradharris.dispatch.feedback.extension`.
+
+One-time setup: create the app record in App Store Connect (My Apps → New
+App → iOS, bundle ID `dev.bradharris.dispatch.feedback`). If this Mac has no Apple
+Distribution certificate yet, Xcode offers to create one during the first
+Distribute App.
+
+Per release:
+
+1. `pnpm --filter @dispatch/browser-extension build:safari`
+2. Verify `MARKETING_VERSION` matches `package.json` (a vitest check enforces
+ this) and bump `CURRENT_PROJECT_VERSION` (build number) for each upload.
+3. Select "Any iOS Device (arm64)" → Product → Archive → Distribute App →
+ TestFlight & App Store Connect. (CLI equivalent: `xcodebuild archive` +
+ `xcodebuild -exportArchive` with an App Store Connect API key via
+ `-authenticationKeyPath/-authenticationKeyID/-authenticationKeyIssuerID`;
+ headless export cannot mint distribution profiles without one.)
+4. In App Store Connect, add yourself as an internal tester and install the
+ build via TestFlight on the iPad.
+5. On the iPad: Settings → Apps → Safari → Extensions → enable Dispatch
+ Browser Feedback, then allow it on the sites you want to inspect (or
+ "Other Websites" for everything). Site access can also be granted in-page
+ from the aA / puzzle menu the first time you use the picker.
+
+Pairing works the same as Chrome: open the extension popup, enter your
+Dispatch URL, approve the code in Dispatch settings. The popup may close while
+the approval tab is open — pairing continues in the background; reopen the
+popup to see the connected state.
+
## Release checks
The manifest and package versions must match; an extension test enforces this.
diff --git a/apps/browser-extension/manifest.safari.json b/apps/browser-extension/manifest.safari.json
new file mode 100644
index 00000000..63e66096
--- /dev/null
+++ b/apps/browser-extension/manifest.safari.json
@@ -0,0 +1,25 @@
+{
+ "manifest_version": 3,
+ "name": "Dispatch Browser Feedback",
+ "description": "Select live page elements and send focused feedback to a Dispatch agent.",
+ "version": "0.29.0",
+ "permissions": ["scripting", "storage", "activeTab"],
+ "host_permissions": ["http://*/*", "https://*/*"],
+ "icons": {
+ "16": "icons/icon-16.png",
+ "32": "icons/icon-32.png",
+ "48": "icons/icon-48.png",
+ "128": "icons/icon-128.png"
+ },
+ "action": {
+ "default_title": "Dispatch feedback",
+ "default_popup": "popup.html",
+ "default_icon": {
+ "16": "icons/icon-16.png",
+ "32": "icons/icon-32.png"
+ }
+ },
+ "background": {
+ "service_worker": "background.js"
+ }
+}
diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json
index 61a7e588..463c62d6 100644
--- a/apps/browser-extension/package.json
+++ b/apps/browser-extension/package.json
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"build": "vite build && vite build --config vite.picker.config.ts",
+ "build:safari": "vite build --config vite.safari.config.ts && vite build --config vite.safari.background.config.ts && vite build --config vite.safari.overlay.config.ts",
"package": "pnpm run build && rm -f dist/dispatch-browser-feedback.zip && cd dist/unpacked && zip -qr ../dispatch-browser-feedback.zip .",
"check": "tsc --noEmit",
"test": "vitest run"
diff --git a/apps/browser-extension/popup.html b/apps/browser-extension/popup.html
new file mode 100644
index 00000000..85dc6f13
--- /dev/null
+++ b/apps/browser-extension/popup.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Dispatch feedback
+
+
+
+
+
+
diff --git a/apps/browser-extension/public/manifest.json b/apps/browser-extension/public/manifest.json
index 9d9c8bdb..83368042 100644
--- a/apps/browser-extension/public/manifest.json
+++ b/apps/browser-extension/public/manifest.json
@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Dispatch Browser Feedback",
"description": "Select live page elements and send focused feedback to a Dispatch agent.",
- "version": "0.28.4",
+ "version": "0.29.0",
"minimum_chrome_version": "114",
"permissions": ["scripting", "storage", "sidePanel"],
"optional_host_permissions": ["http://*/*", "https://*/*"],
diff --git a/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback Extension/Info.plist b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback Extension/Info.plist
new file mode 100644
index 00000000..9ee504dc
--- /dev/null
+++ b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback Extension/Info.plist
@@ -0,0 +1,13 @@
+
+
+
+
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.Safari.web-extension
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).SafariWebExtensionHandler
+
+
+
diff --git a/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback Extension/SafariWebExtensionHandler.swift b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback Extension/SafariWebExtensionHandler.swift
new file mode 100644
index 00000000..15aab564
--- /dev/null
+++ b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback Extension/SafariWebExtensionHandler.swift
@@ -0,0 +1,42 @@
+//
+// SafariWebExtensionHandler.swift
+// Dispatch Feedback Extension
+//
+// Created by Brad Harris on 7/16/26.
+//
+
+import SafariServices
+import os.log
+
+class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
+
+ func beginRequest(with context: NSExtensionContext) {
+ let request = context.inputItems.first as? NSExtensionItem
+
+ let profile: UUID?
+ if #available(iOS 17.0, macOS 14.0, *) {
+ profile = request?.userInfo?[SFExtensionProfileKey] as? UUID
+ } else {
+ profile = request?.userInfo?["profile"] as? UUID
+ }
+
+ let message: Any?
+ if #available(iOS 15.0, macOS 11.0, *) {
+ message = request?.userInfo?[SFExtensionMessageKey]
+ } else {
+ message = request?.userInfo?["message"]
+ }
+
+ os_log(.default, "Received message from browser.runtime.sendNativeMessage: %@ (profile: %@)", String(describing: message), profile?.uuidString ?? "none")
+
+ let response = NSExtensionItem()
+ if #available(iOS 15.0, macOS 11.0, *) {
+ response.userInfo = [ SFExtensionMessageKey: [ "echo": message ] ]
+ } else {
+ response.userInfo = [ "message": [ "echo": message ] ]
+ }
+
+ context.completeRequest(returningItems: [ response ], completionHandler: nil)
+ }
+
+}
diff --git a/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback.xcodeproj/project.pbxproj b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback.xcodeproj/project.pbxproj
new file mode 100644
index 00000000..3ebb2013
--- /dev/null
+++ b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback.xcodeproj/project.pbxproj
@@ -0,0 +1,630 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ A22ADA893009BD5900FAA80E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A22ADA883009BD5900FAA80E /* AppDelegate.swift */; };
+ A22ADA8D3009BD5900FAA80E /* Main.html in Resources */ = {isa = PBXBuildFile; fileRef = A22ADA8B3009BD5900FAA80E /* Main.html */; };
+ A22ADA8F3009BD5900FAA80E /* Icon.png in Resources */ = {isa = PBXBuildFile; fileRef = A22ADA8E3009BD5900FAA80E /* Icon.png */; };
+ A22ADA913009BD5900FAA80E /* Style.css in Resources */ = {isa = PBXBuildFile; fileRef = A22ADA903009BD5900FAA80E /* Style.css */; };
+ A22ADA933009BD5900FAA80E /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A22ADA923009BD5900FAA80E /* SceneDelegate.swift */; };
+ A22ADA953009BD5900FAA80E /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A22ADA943009BD5900FAA80E /* ViewController.swift */; };
+ A22ADA983009BD5900FAA80E /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A22ADA963009BD5900FAA80E /* LaunchScreen.storyboard */; };
+ A22ADA9B3009BD5900FAA80E /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A22ADA993009BD5900FAA80E /* Main.storyboard */; };
+ A22ADAA33009BD5A00FAA80E /* Dispatch Feedback Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A22ADAA23009BD5A00FAA80E /* Dispatch Feedback Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ A22ADAA83009BD5A00FAA80E /* SafariWebExtensionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A22ADAA73009BD5A00FAA80E /* SafariWebExtensionHandler.swift */; };
+ A22ADAAA3009BD5A00FAA80E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A22ADA9C3009BD5A00FAA80E /* Assets.xcassets */; };
+ A22ADABC3009BD5A00FAA80E /* popup.js in Resources */ = {isa = PBXBuildFile; fileRef = A22ADAB53009BD5A00FAA80E /* popup.js */; };
+ A22ADABD3009BD5A00FAA80E /* background.js in Resources */ = {isa = PBXBuildFile; fileRef = A22ADAB63009BD5A00FAA80E /* background.js */; };
+ A22ADABE3009BD5A00FAA80E /* popup.html in Resources */ = {isa = PBXBuildFile; fileRef = A22ADAB73009BD5A00FAA80E /* popup.html */; };
+ A22ADABF3009BD5A00FAA80E /* icons in Resources */ = {isa = PBXBuildFile; fileRef = A22ADAB83009BD5A00FAA80E /* icons */; };
+ A22ADAC03009BD5A00FAA80E /* manifest.json in Resources */ = {isa = PBXBuildFile; fileRef = A22ADAB93009BD5A00FAA80E /* manifest.json */; };
+ A22ADAC13009BD5A00FAA80E /* feedback-overlay.js in Resources */ = {isa = PBXBuildFile; fileRef = A22ADABA3009BD5A00FAA80E /* feedback-overlay.js */; };
+ A22ADAC23009BD5A00FAA80E /* assets in Resources */ = {isa = PBXBuildFile; fileRef = A22ADABB3009BD5A00FAA80E /* assets */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ A22ADAA43009BD5A00FAA80E /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = A22ADA7D3009BD5900FAA80E /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = A22ADAA13009BD5A00FAA80E;
+ remoteInfo = "Dispatch Feedback Extension";
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ A22ADAB03009BD5A00FAA80E /* Embed Foundation Extensions */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 13;
+ files = (
+ A22ADAA33009BD5A00FAA80E /* Dispatch Feedback Extension.appex in Embed Foundation Extensions */,
+ );
+ name = "Embed Foundation Extensions";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ A22ADA853009BD5900FAA80E /* Dispatch Feedback.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Dispatch Feedback.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+ A22ADA883009BD5900FAA80E /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ A22ADA8C3009BD5900FAA80E /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = Base; path = Base.lproj/Main.html; sourceTree = ""; };
+ A22ADA8E3009BD5900FAA80E /* Icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = Icon.png; sourceTree = ""; };
+ A22ADA903009BD5900FAA80E /* Style.css */ = {isa = PBXFileReference; lastKnownFileType = text.css; path = Style.css; sourceTree = ""; };
+ A22ADA923009BD5900FAA80E /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; };
+ A22ADA943009BD5900FAA80E /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; };
+ A22ADA973009BD5900FAA80E /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
+ A22ADA9A3009BD5900FAA80E /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
+ A22ADA9C3009BD5A00FAA80E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ A22ADA9D3009BD5A00FAA80E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ A22ADAA23009BD5A00FAA80E /* Dispatch Feedback Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Dispatch Feedback Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
+ A22ADAA73009BD5A00FAA80E /* SafariWebExtensionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariWebExtensionHandler.swift; sourceTree = ""; };
+ A22ADAA93009BD5A00FAA80E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ A22ADAB53009BD5A00FAA80E /* popup.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = popup.js; path = ../../../dist/safari/unpacked/popup.js; sourceTree = ""; };
+ A22ADAB63009BD5A00FAA80E /* background.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = background.js; path = ../../../dist/safari/unpacked/background.js; sourceTree = ""; };
+ A22ADAB73009BD5A00FAA80E /* popup.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = popup.html; path = ../../../dist/safari/unpacked/popup.html; sourceTree = ""; };
+ A22ADAB83009BD5A00FAA80E /* icons */ = {isa = PBXFileReference; lastKnownFileType = folder; name = icons; path = ../../../dist/safari/unpacked/icons; sourceTree = ""; };
+ A22ADAB93009BD5A00FAA80E /* manifest.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = manifest.json; path = ../../../dist/safari/unpacked/manifest.json; sourceTree = ""; };
+ A22ADABA3009BD5A00FAA80E /* feedback-overlay.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = "feedback-overlay.js"; path = "../../../dist/safari/unpacked/feedback-overlay.js"; sourceTree = ""; };
+ A22ADABB3009BD5A00FAA80E /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; name = assets; path = ../../../dist/safari/unpacked/assets; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ A22ADA823009BD5900FAA80E /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A22ADA9F3009BD5A00FAA80E /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ A22ADA7C3009BD5900FAA80E = {
+ isa = PBXGroup;
+ children = (
+ A22ADA873009BD5900FAA80E /* Dispatch Feedback */,
+ A22ADAA63009BD5A00FAA80E /* Dispatch Feedback Extension */,
+ A22ADA863009BD5900FAA80E /* Products */,
+ );
+ sourceTree = "";
+ };
+ A22ADA863009BD5900FAA80E /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ A22ADA853009BD5900FAA80E /* Dispatch Feedback.app */,
+ A22ADAA23009BD5A00FAA80E /* Dispatch Feedback Extension.appex */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ A22ADA873009BD5900FAA80E /* Dispatch Feedback */ = {
+ isa = PBXGroup;
+ children = (
+ A22ADA883009BD5900FAA80E /* AppDelegate.swift */,
+ A22ADA923009BD5900FAA80E /* SceneDelegate.swift */,
+ A22ADA943009BD5900FAA80E /* ViewController.swift */,
+ A22ADA963009BD5900FAA80E /* LaunchScreen.storyboard */,
+ A22ADA993009BD5900FAA80E /* Main.storyboard */,
+ A22ADA9C3009BD5A00FAA80E /* Assets.xcassets */,
+ A22ADA9D3009BD5A00FAA80E /* Info.plist */,
+ A22ADA8A3009BD5900FAA80E /* Resources */,
+ );
+ path = "Dispatch Feedback";
+ sourceTree = "";
+ };
+ A22ADA8A3009BD5900FAA80E /* Resources */ = {
+ isa = PBXGroup;
+ children = (
+ A22ADA8B3009BD5900FAA80E /* Main.html */,
+ A22ADA8E3009BD5900FAA80E /* Icon.png */,
+ A22ADA903009BD5900FAA80E /* Style.css */,
+ );
+ path = Resources;
+ sourceTree = "";
+ };
+ A22ADAA63009BD5A00FAA80E /* Dispatch Feedback Extension */ = {
+ isa = PBXGroup;
+ children = (
+ A22ADAB43009BD5A00FAA80E /* Resources */,
+ A22ADAA73009BD5A00FAA80E /* SafariWebExtensionHandler.swift */,
+ A22ADAA93009BD5A00FAA80E /* Info.plist */,
+ );
+ path = "Dispatch Feedback Extension";
+ sourceTree = "";
+ };
+ A22ADAB43009BD5A00FAA80E /* Resources */ = {
+ isa = PBXGroup;
+ children = (
+ A22ADAB53009BD5A00FAA80E /* popup.js */,
+ A22ADAB63009BD5A00FAA80E /* background.js */,
+ A22ADAB73009BD5A00FAA80E /* popup.html */,
+ A22ADAB83009BD5A00FAA80E /* icons */,
+ A22ADAB93009BD5A00FAA80E /* manifest.json */,
+ A22ADABA3009BD5A00FAA80E /* feedback-overlay.js */,
+ A22ADABB3009BD5A00FAA80E /* assets */,
+ );
+ name = Resources;
+ path = "Dispatch Feedback Extension";
+ sourceTree = SOURCE_ROOT;
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ A22ADA843009BD5900FAA80E /* Dispatch Feedback */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = A22ADAB13009BD5A00FAA80E /* Build configuration list for PBXNativeTarget "Dispatch Feedback" */;
+ buildPhases = (
+ A22ADA813009BD5900FAA80E /* Sources */,
+ A22ADA823009BD5900FAA80E /* Frameworks */,
+ A22ADA833009BD5900FAA80E /* Resources */,
+ A22ADAB03009BD5A00FAA80E /* Embed Foundation Extensions */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ A22ADAA53009BD5A00FAA80E /* PBXTargetDependency */,
+ );
+ name = "Dispatch Feedback";
+ packageProductDependencies = (
+ );
+ productName = "Dispatch Feedback";
+ productReference = A22ADA853009BD5900FAA80E /* Dispatch Feedback.app */;
+ productType = "com.apple.product-type.application";
+ };
+ A22ADAA13009BD5A00FAA80E /* Dispatch Feedback Extension */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = A22ADAAD3009BD5A00FAA80E /* Build configuration list for PBXNativeTarget "Dispatch Feedback Extension" */;
+ buildPhases = (
+ A22ADA9E3009BD5A00FAA80E /* Sources */,
+ A22ADA9F3009BD5A00FAA80E /* Frameworks */,
+ A22ADAA03009BD5A00FAA80E /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = "Dispatch Feedback Extension";
+ packageProductDependencies = (
+ );
+ productName = "Dispatch Feedback Extension";
+ productReference = A22ADAA23009BD5A00FAA80E /* Dispatch Feedback Extension.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ A22ADA7D3009BD5900FAA80E /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 2660;
+ LastUpgradeCheck = 2660;
+ TargetAttributes = {
+ A22ADA843009BD5900FAA80E = {
+ CreatedOnToolsVersion = 26.6;
+ };
+ A22ADAA13009BD5A00FAA80E = {
+ CreatedOnToolsVersion = 26.6;
+ };
+ };
+ };
+ buildConfigurationList = A22ADA803009BD5900FAA80E /* Build configuration list for PBXProject "Dispatch Feedback" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = A22ADA7C3009BD5900FAA80E;
+ minimizedProjectReferenceProxies = 1;
+ preferredProjectObjectVersion = 77;
+ productRefGroup = A22ADA863009BD5900FAA80E /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ A22ADA843009BD5900FAA80E /* Dispatch Feedback */,
+ A22ADAA13009BD5A00FAA80E /* Dispatch Feedback Extension */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ A22ADA833009BD5900FAA80E /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A22ADA8F3009BD5900FAA80E /* Icon.png in Resources */,
+ A22ADA9B3009BD5900FAA80E /* Main.storyboard in Resources */,
+ A22ADA983009BD5900FAA80E /* LaunchScreen.storyboard in Resources */,
+ A22ADA8D3009BD5900FAA80E /* Main.html in Resources */,
+ A22ADAAA3009BD5A00FAA80E /* Assets.xcassets in Resources */,
+ A22ADA913009BD5900FAA80E /* Style.css in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A22ADAA03009BD5A00FAA80E /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A22ADABD3009BD5A00FAA80E /* background.js in Resources */,
+ A22ADABF3009BD5A00FAA80E /* icons in Resources */,
+ A22ADAC23009BD5A00FAA80E /* assets in Resources */,
+ A22ADAC03009BD5A00FAA80E /* manifest.json in Resources */,
+ A22ADABE3009BD5A00FAA80E /* popup.html in Resources */,
+ A22ADAC13009BD5A00FAA80E /* feedback-overlay.js in Resources */,
+ A22ADABC3009BD5A00FAA80E /* popup.js in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ A22ADA813009BD5900FAA80E /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A22ADA953009BD5900FAA80E /* ViewController.swift in Sources */,
+ A22ADA893009BD5900FAA80E /* AppDelegate.swift in Sources */,
+ A22ADA933009BD5900FAA80E /* SceneDelegate.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A22ADA9E3009BD5A00FAA80E /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A22ADAA83009BD5A00FAA80E /* SafariWebExtensionHandler.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ A22ADAA53009BD5A00FAA80E /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = A22ADAA13009BD5A00FAA80E /* Dispatch Feedback Extension */;
+ targetProxy = A22ADAA43009BD5A00FAA80E /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+ A22ADA8B3009BD5900FAA80E /* Main.html */ = {
+ isa = PBXVariantGroup;
+ children = (
+ A22ADA8C3009BD5900FAA80E /* Base */,
+ );
+ name = Main.html;
+ sourceTree = "";
+ };
+ A22ADA963009BD5900FAA80E /* LaunchScreen.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ A22ADA973009BD5900FAA80E /* Base */,
+ );
+ name = LaunchScreen.storyboard;
+ sourceTree = "";
+ };
+ A22ADA993009BD5900FAA80E /* Main.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ A22ADA9A3009BD5900FAA80E /* Base */,
+ );
+ name = Main.storyboard;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ A22ADAAB3009BD5A00FAA80E /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.5;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ A22ADAAC3009BD5A00FAA80E /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.5;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ A22ADAAE3009BD5A00FAA80E /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_TEAM = ML8BQ6D727;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "Dispatch Feedback Extension/Info.plist";
+ INFOPLIST_KEY_CFBundleDisplayName = "Dispatch Feedback Extension";
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = 0.29.0;
+ OTHER_LDFLAGS = (
+ "-framework",
+ SafariServices,
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = dev.bradharris.dispatch.feedback.extension;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ A22ADAAF3009BD5A00FAA80E /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_TEAM = ML8BQ6D727;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "Dispatch Feedback Extension/Info.plist";
+ INFOPLIST_KEY_CFBundleDisplayName = "Dispatch Feedback Extension";
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = 0.29.0;
+ OTHER_LDFLAGS = (
+ "-framework",
+ SafariServices,
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = dev.bradharris.dispatch.feedback.extension;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+ A22ADAB23009BD5A00FAA80E /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_TEAM = ML8BQ6D727;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "Dispatch Feedback/Info.plist";
+ INFOPLIST_KEY_CFBundleDisplayName = "Dispatch Feedback";
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
+ INFOPLIST_KEY_UIMainStoryboardFile = Main;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 0.29.0;
+ OTHER_LDFLAGS = (
+ "-framework",
+ SafariServices,
+ "-framework",
+ WebKit,
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = dev.bradharris.dispatch.feedback;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ A22ADAB33009BD5A00FAA80E /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_TEAM = ML8BQ6D727;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "Dispatch Feedback/Info.plist";
+ INFOPLIST_KEY_CFBundleDisplayName = "Dispatch Feedback";
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
+ INFOPLIST_KEY_UIMainStoryboardFile = Main;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 0.29.0;
+ OTHER_LDFLAGS = (
+ "-framework",
+ SafariServices,
+ "-framework",
+ WebKit,
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = dev.bradharris.dispatch.feedback;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ A22ADA803009BD5900FAA80E /* Build configuration list for PBXProject "Dispatch Feedback" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ A22ADAAB3009BD5A00FAA80E /* Debug */,
+ A22ADAAC3009BD5A00FAA80E /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ A22ADAAD3009BD5A00FAA80E /* Build configuration list for PBXNativeTarget "Dispatch Feedback Extension" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ A22ADAAE3009BD5A00FAA80E /* Debug */,
+ A22ADAAF3009BD5A00FAA80E /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ A22ADAB13009BD5A00FAA80E /* Build configuration list for PBXNativeTarget "Dispatch Feedback" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ A22ADAB23009BD5A00FAA80E /* Debug */,
+ A22ADAB33009BD5A00FAA80E /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = A22ADA7D3009BD5900FAA80E /* Project object */;
+}
diff --git a/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 00000000..919434a6
--- /dev/null
+++ b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/AppDelegate.swift b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/AppDelegate.swift
new file mode 100644
index 00000000..12dbc9cd
--- /dev/null
+++ b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/AppDelegate.swift
@@ -0,0 +1,24 @@
+//
+// AppDelegate.swift
+// Dispatch Feedback
+//
+// Created by Brad Harris on 7/16/26.
+//
+
+import UIKit
+
+@main
+class AppDelegate: UIResponder, UIApplicationDelegate {
+
+ var window: UIWindow?
+
+ func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+ // Override point for customization after application launch.
+ return true
+ }
+
+ func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
+ return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
+ }
+
+}
diff --git a/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 00000000..0afb3cf0
--- /dev/null
+++ b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors": [
+ {
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 00000000..1d73419f
--- /dev/null
+++ b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "images": [
+ {
+ "size": "1024x1024",
+ "idiom": "universal",
+ "filename": "universal-icon-1024@1x.png",
+ "platform": "ios"
+ },
+ {
+ "size": "1024x1024",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ],
+ "idiom": "universal",
+ "filename": "universal-icon-1024@1x.png",
+ "platform": "ios"
+ },
+ {
+ "size": "1024x1024",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "tinted"
+ }
+ ],
+ "idiom": "universal",
+ "filename": "universal-icon-1024@1x.png",
+ "platform": "ios"
+ }
+ ],
+ "info": {
+ "version": 1,
+ "author": "xcode"
+ }
+}
diff --git a/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/AppIcon.appiconset/universal-icon-1024@1x.png b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/AppIcon.appiconset/universal-icon-1024@1x.png
new file mode 100644
index 00000000..1ba3d1a5
Binary files /dev/null and b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/AppIcon.appiconset/universal-icon-1024@1x.png differ
diff --git a/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/Contents.json b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/Contents.json
new file mode 100644
index 00000000..74d6a722
--- /dev/null
+++ b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/LargeIcon.imageset/Contents.json b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/LargeIcon.imageset/Contents.json
new file mode 100644
index 00000000..053d3602
--- /dev/null
+++ b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/LargeIcon.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images": [
+ {
+ "idiom": "universal",
+ "scale": "1x",
+ "filename": "icon-128.png"
+ },
+ {
+ "idiom": "universal",
+ "scale": "2x"
+ },
+ {
+ "idiom": "universal",
+ "scale": "3x"
+ }
+ ],
+ "info": {
+ "version": 1,
+ "author": "xcode"
+ }
+}
diff --git a/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/LargeIcon.imageset/icon-128.png b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/LargeIcon.imageset/icon-128.png
new file mode 100644
index 00000000..43ef9c75
Binary files /dev/null and b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/LargeIcon.imageset/icon-128.png differ
diff --git a/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Base.lproj/LaunchScreen.storyboard b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 00000000..620a70cb
--- /dev/null
+++ b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Base.lproj/Main.storyboard b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Base.lproj/Main.storyboard
new file mode 100644
index 00000000..618dfce1
--- /dev/null
+++ b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Base.lproj/Main.storyboard
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Info.plist b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Info.plist
new file mode 100644
index 00000000..065cc4c2
--- /dev/null
+++ b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Info.plist
@@ -0,0 +1,27 @@
+
+
+
+
+ SFSafariWebExtensionConverterVersion
+ 26.6
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneConfigurationName
+ Default Configuration
+ UISceneDelegateClassName
+ $(PRODUCT_MODULE_NAME).SceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
+
+
+
diff --git a/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Resources/Base.lproj/Main.html b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Resources/Base.lproj/Main.html
new file mode 100644
index 00000000..2473f743
--- /dev/null
+++ b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Resources/Base.lproj/Main.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ You can turn on Dispatch Feedback’s Safari extension in Settings.
+
+
diff --git a/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Resources/Icon.png b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Resources/Icon.png
new file mode 100644
index 00000000..43ef9c75
Binary files /dev/null and b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Resources/Icon.png differ
diff --git a/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Resources/Style.css b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Resources/Style.css
new file mode 100644
index 00000000..ed591d39
--- /dev/null
+++ b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Resources/Style.css
@@ -0,0 +1,29 @@
+* {
+ -webkit-user-select: none;
+ -webkit-user-drag: none;
+ cursor: default;
+}
+
+:root {
+ color-scheme: light dark;
+
+ --spacing: 20px;
+}
+
+html {
+ height: 100%;
+}
+
+body {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+
+ gap: var(--spacing);
+ margin: 0 calc(var(--spacing) * 2);
+ height: 100%;
+
+ font: -apple-system-short-body;
+ text-align: center;
+}
diff --git a/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/SceneDelegate.swift b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/SceneDelegate.swift
new file mode 100644
index 00000000..7ff4227f
--- /dev/null
+++ b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/SceneDelegate.swift
@@ -0,0 +1,18 @@
+//
+// SceneDelegate.swift
+// Dispatch Feedback
+//
+// Created by Brad Harris on 7/16/26.
+//
+
+import UIKit
+
+class SceneDelegate: UIResponder, UIWindowSceneDelegate {
+
+ var window: UIWindow?
+
+ func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
+ guard let _ = (scene as? UIWindowScene) else { return }
+ }
+
+}
diff --git a/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/ViewController.swift b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/ViewController.swift
new file mode 100644
index 00000000..8c9c8a74
--- /dev/null
+++ b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/ViewController.swift
@@ -0,0 +1,34 @@
+//
+// ViewController.swift
+// Dispatch Feedback
+//
+// Created by Brad Harris on 7/16/26.
+//
+
+import UIKit
+import WebKit
+
+class ViewController: UIViewController, WKNavigationDelegate, WKScriptMessageHandler {
+
+ @IBOutlet var webView: WKWebView!
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ self.webView.navigationDelegate = self
+ self.webView.scrollView.isScrollEnabled = false
+
+ self.webView.configuration.userContentController.add(self, name: "controller")
+
+ self.webView.loadFileURL(Bundle.main.url(forResource: "Main", withExtension: "html")!, allowingReadAccessTo: Bundle.main.resourceURL!)
+ }
+
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ // Override point for customization.
+ }
+
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
+ // Override point for customization.
+ }
+
+}
diff --git a/apps/browser-extension/src/lib/agent-memory.ts b/apps/browser-extension/src/lib/agent-memory.ts
new file mode 100644
index 00000000..f06554e7
--- /dev/null
+++ b/apps/browser-extension/src/lib/agent-memory.ts
@@ -0,0 +1,32 @@
+export const SELECTIONS_KEY = "dispatchAgentSelections";
+
+export interface SelectionStorage {
+ get(key: string): Promise>;
+ set(items: Record): Promise;
+}
+
+export function agentSelectionKey(baseUrl: string, origin: string): string {
+ return `${baseUrl}|${origin}`;
+}
+
+export async function loadRememberedAgentId(
+ storage: SelectionStorage,
+ baseUrl: string,
+ origin: string
+): Promise {
+ const stored = await storage.get(SELECTIONS_KEY);
+ const selections = (stored[SELECTIONS_KEY] ?? {}) as Record;
+ return selections[agentSelectionKey(baseUrl, origin)] ?? null;
+}
+
+export async function rememberAgentSelection(
+ storage: SelectionStorage,
+ baseUrl: string,
+ origin: string,
+ agentId: string
+): Promise {
+ const stored = await storage.get(SELECTIONS_KEY);
+ const selections = (stored[SELECTIONS_KEY] ?? {}) as Record;
+ selections[agentSelectionKey(baseUrl, origin)] = agentId;
+ await storage.set({ [SELECTIONS_KEY]: selections });
+}
diff --git a/apps/browser-extension/src/lib/device-name.test.ts b/apps/browser-extension/src/lib/device-name.test.ts
index 99dd412d..3fa10d19 100644
--- a/apps/browser-extension/src/lib/device-name.test.ts
+++ b/apps/browser-extension/src/lib/device-name.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { buildDeviceName } from "./device-name";
+import { buildDeviceName, buildSafariDeviceName } from "./device-name";
describe("buildDeviceName", () => {
it("creates a recognizable profile-specific browser label", () => {
@@ -14,3 +14,39 @@ describe("buildDeviceName", () => {
);
});
});
+
+describe("buildSafariDeviceName", () => {
+ const IPAD_UA =
+ "Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15";
+ const IPAD_DESKTOP_UA =
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15";
+ const IPHONE_UA =
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15";
+
+ it("labels iPads including desktop-mode user agents", () => {
+ expect(buildSafariDeviceName("ios", IPAD_UA, 5, "a1b2")).toBe(
+ "Safari on iPadOS · A1B2"
+ );
+ expect(buildSafariDeviceName("ios", IPAD_DESKTOP_UA, 5, "a1b2")).toBe(
+ "Safari on iPadOS · A1B2"
+ );
+ });
+
+ it("labels iPhones as iOS", () => {
+ expect(buildSafariDeviceName("ios", IPHONE_UA, 5, "c3d4")).toBe(
+ "Safari on iOS · C3D4"
+ );
+ });
+
+ it("labels Macs without relying on touch points", () => {
+ expect(buildSafariDeviceName("mac", IPAD_DESKTOP_UA, 0, "e5f6")).toBe(
+ "Safari on macOS · E5F6"
+ );
+ });
+
+ it("keeps unknown platforms understandable", () => {
+ expect(buildSafariDeviceName("unknown", "", 0, "beef")).toBe(
+ "Safari on this device · BEEF"
+ );
+ });
+});
diff --git a/apps/browser-extension/src/lib/device-name.ts b/apps/browser-extension/src/lib/device-name.ts
index 4174017e..078e32e0 100644
--- a/apps/browser-extension/src/lib/device-name.ts
+++ b/apps/browser-extension/src/lib/device-name.ts
@@ -11,3 +11,24 @@ export function buildDeviceName(platform: string, suffix: string): string {
const label = PLATFORM_LABELS[platform] ?? "this device";
return `Chrome on ${label} · ${suffix.toUpperCase()}`;
}
+
+export function buildSafariDeviceName(
+ platform: string,
+ userAgent: string,
+ maxTouchPoints: number,
+ suffix: string
+): string {
+ let label: string;
+ if (platform === "ios") {
+ // Desktop-mode iPad UAs say "Macintosh" but still report touch points.
+ const isIpad =
+ userAgent.includes("iPad") ||
+ (userAgent.includes("Macintosh") && maxTouchPoints > 1);
+ label = isIpad ? "iPadOS" : "iOS";
+ } else if (platform === "mac") {
+ label = "macOS";
+ } else {
+ label = PLATFORM_LABELS[platform] ?? "this device";
+ }
+ return `Safari on ${label} · ${suffix.toUpperCase()}`;
+}
diff --git a/apps/browser-extension/src/lib/extension-api.ts b/apps/browser-extension/src/lib/extension-api.ts
new file mode 100644
index 00000000..0328e8fa
--- /dev/null
+++ b/apps/browser-extension/src/lib/extension-api.ts
@@ -0,0 +1,13 @@
+/**
+ * Safari exposes the promise-based WebExtension API on `browser`; Chrome on
+ * `chrome`. Every call this codebase makes is promise-style and structurally
+ * identical across both, so a namespace alias is the entire compatibility
+ * layer — no polyfill dependency needed.
+ */
+const globals = globalThis as typeof globalThis & {
+ browser?: typeof chrome;
+ chrome?: typeof chrome;
+};
+
+export const api: typeof chrome =
+ globals.browser ?? (globals.chrome as typeof chrome);
diff --git a/apps/browser-extension/src/lib/manifest.test.ts b/apps/browser-extension/src/lib/manifest.test.ts
index 9d870d75..38126230 100644
--- a/apps/browser-extension/src/lib/manifest.test.ts
+++ b/apps/browser-extension/src/lib/manifest.test.ts
@@ -1,7 +1,10 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import packageJson from "../../package.json";
import manifest from "../../public/manifest.json";
+import safariManifest from "../../manifest.safari.json";
describe("extension manifest", () => {
it("stays synchronized with the extension package version", () => {
@@ -19,3 +22,45 @@ describe("extension manifest", () => {
]);
});
});
+
+describe("safari extension manifest", () => {
+ it("stays synchronized with the extension package version", () => {
+ expect(safariManifest.version).toBe(packageJson.version);
+ });
+
+ it("declares the Safari-appropriate capabilities", () => {
+ expect(safariManifest.manifest_version).toBe(3);
+ expect(safariManifest.background.service_worker).toBe("background.js");
+ expect(safariManifest.action.default_popup).toBe("popup.html");
+ // Safari mediates host access per site itself; activeTab covers
+ // popup-invoked injection and there is no side panel on Safari.
+ expect(safariManifest.permissions).toEqual([
+ "scripting",
+ "storage",
+ "activeTab",
+ ]);
+ expect(safariManifest.host_permissions).toEqual([
+ "http://*/*",
+ "https://*/*",
+ ]);
+ expect(safariManifest).not.toHaveProperty("side_panel");
+ expect(safariManifest).not.toHaveProperty("optional_host_permissions");
+ });
+
+ it("keeps the Xcode project marketing version synchronized", () => {
+ const pbxproj = readFileSync(
+ resolve(
+ import.meta.dirname,
+ "../../safari/Dispatch Feedback/Dispatch Feedback.xcodeproj/project.pbxproj"
+ ),
+ "utf8"
+ );
+ const versions = [...pbxproj.matchAll(/MARKETING_VERSION = ([^;]+);/g)].map(
+ (match) => match[1]
+ );
+ expect(versions.length).toBeGreaterThan(0);
+ for (const version of versions) {
+ expect(version).toBe(packageJson.version);
+ }
+ });
+});
diff --git a/apps/browser-extension/src/lib/worker-core.ts b/apps/browser-extension/src/lib/worker-core.ts
new file mode 100644
index 00000000..59a5d8f5
--- /dev/null
+++ b/apps/browser-extension/src/lib/worker-core.ts
@@ -0,0 +1,239 @@
+import {
+ type BrowserSelection,
+ type ConnectionStatus,
+ type DispatchAgent,
+ type WorkerRequest,
+ type WorkerResponse,
+} from "../types";
+import { api } from "./extension-api";
+import { normalizeDispatchBaseUrl } from "./dispatch-url";
+
+export const CONNECTION_KEY = "dispatchConnection";
+const DEVICE_NAME_KEY = "dispatchDeviceName";
+const REQUEST_TIMEOUT_MS = 15_000;
+
+export interface StoredConnection {
+ baseUrl: string;
+ token: string;
+}
+
+export interface PairingStartResponse {
+ pairingId: string;
+ pairingSecret: string;
+ code: string;
+ verificationPath: string;
+ expiresAt: string;
+}
+
+export interface PairingExchangeResponse {
+ status: "pending" | "approved";
+ token?: string;
+}
+
+export type DeviceNameBuilder = (os: string, suffix: string) => string;
+
+export class HttpStatusError extends Error {
+ constructor(
+ message: string,
+ readonly status: number,
+ readonly submissionTerminalFailure = false
+ ) {
+ super(message);
+ }
+}
+
+export async function getConnection(): Promise {
+ const stored = await api.storage.local.get(CONNECTION_KEY);
+ return (stored[CONNECTION_KEY] as StoredConnection | undefined) ?? null;
+}
+
+async function getDeviceName(buildName: DeviceNameBuilder): Promise {
+ const stored = await api.storage.local.get(DEVICE_NAME_KEY);
+ const existing = stored[DEVICE_NAME_KEY];
+ if (typeof existing === "string" && existing.length > 0) return existing;
+
+ const platform = await api.runtime.getPlatformInfo();
+ const name = buildName(
+ platform.os,
+ crypto.randomUUID().replaceAll("-", "").slice(0, 4)
+ );
+ await api.storage.local.set({ [DEVICE_NAME_KEY]: name });
+ return name;
+}
+
+export async function fetchJson(
+ url: string,
+ init: RequestInit,
+ expectedStatuses: number[] = [200]
+): Promise {
+ let response: Response;
+ try {
+ response = await fetch(url, {
+ ...init,
+ redirect: "error",
+ signal: init.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ });
+ } catch (error) {
+ if (
+ error instanceof DOMException &&
+ (error.name === "TimeoutError" || error.name === "AbortError")
+ ) {
+ throw new Error("Dispatch did not respond in time.");
+ }
+ throw error;
+ }
+ const body = (await response.json().catch(() => null)) as
+ | (T & {
+ message?: string;
+ error?: string;
+ status?: unknown;
+ submissionId?: unknown;
+ })
+ | null;
+ if (!expectedStatuses.includes(response.status)) {
+ const message =
+ body?.message ?? body?.error ?? `Dispatch returned ${response.status}.`;
+ throw new HttpStatusError(
+ message,
+ response.status,
+ body?.status === "failed" && typeof body.submissionId === "string"
+ );
+ }
+ if (!body) throw new Error("Dispatch returned an empty response.");
+ return body;
+}
+
+export async function authenticatedFetch(
+ path: string,
+ init: RequestInit = {}
+): Promise {
+ const connection = await getConnection();
+ if (!connection) throw new Error("Connect this extension to Dispatch first.");
+
+ try {
+ return await fetchJson(`${connection.baseUrl}${path}`, {
+ ...init,
+ headers: {
+ Authorization: `Bearer ${connection.token}`,
+ "Content-Type": "application/json",
+ ...init.headers,
+ },
+ });
+ } catch (error) {
+ if (error instanceof HttpStatusError && error.status === 401) {
+ await api.storage.local.remove(CONNECTION_KEY);
+ }
+ throw error;
+ }
+}
+
+export async function handleWorkerRequest(
+ request: WorkerRequest,
+ buildName: DeviceNameBuilder
+): Promise {
+ switch (request.type) {
+ case "connection:status": {
+ const connection = await getConnection();
+ const status: ConnectionStatus = connection
+ ? { connected: true, baseUrl: connection.baseUrl }
+ : { connected: false };
+ return { ok: true, data: status };
+ }
+ case "connection:disconnect": {
+ let revokedRemotely = true;
+ try {
+ await authenticatedFetch<{ ok: boolean }>(
+ "/api/v1/browser-extension/token",
+ { method: "DELETE" }
+ );
+ } catch {
+ revokedRemotely = false;
+ } finally {
+ await api.storage.local.remove(CONNECTION_KEY);
+ }
+ return { ok: true, data: { revokedRemotely } };
+ }
+ case "pairing:start": {
+ const baseUrl = normalizeDispatchBaseUrl(request.baseUrl);
+ const deviceName = await getDeviceName(buildName);
+ let pairing: PairingStartResponse;
+ try {
+ pairing = await fetchJson(
+ `${baseUrl}/api/v1/auth/browser-extension/pairings`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ deviceName }),
+ },
+ [200, 201]
+ );
+ } catch (error) {
+ if (error instanceof HttpStatusError && error.status === 404) {
+ throw new Error(
+ "This Dispatch instance does not support browser feedback pairing. Connect to the Dispatch instance managing your agent, not the web app you want to inspect."
+ );
+ }
+ throw error;
+ }
+ return { ok: true, data: { ...pairing, baseUrl } };
+ }
+ case "pairing:exchange": {
+ const baseUrl = normalizeDispatchBaseUrl(request.baseUrl);
+ const result = await fetchJson(
+ `${baseUrl}/api/v1/auth/browser-extension/pairings/${encodeURIComponent(request.pairingId)}/exchange`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ pairingSecret: request.pairingSecret }),
+ }
+ );
+ if (result.status === "approved" && result.token) {
+ await api.storage.local.set({
+ [CONNECTION_KEY]: {
+ baseUrl,
+ token: result.token,
+ } satisfies StoredConnection,
+ });
+ }
+ return { ok: true, data: result };
+ }
+ case "agents:list": {
+ const result = await authenticatedFetch<{ agents: DispatchAgent[] }>(
+ "/api/v1/browser-extension/agents"
+ );
+ return { ok: true, data: result };
+ }
+ case "submission:create": {
+ const body: {
+ clientSubmissionId: string;
+ agentId: string;
+ comment: string;
+ page: BrowserSelection["page"];
+ element: BrowserSelection["element"];
+ } = {
+ clientSubmissionId: request.clientSubmissionId,
+ agentId: request.agentId,
+ comment: request.comment,
+ page: request.selection.page,
+ element: request.selection.element,
+ };
+ const result = await authenticatedFetch(
+ "/api/v1/browser-extension/submissions",
+ { method: "POST", body: JSON.stringify(body) }
+ );
+ return { ok: true, data: result };
+ }
+ }
+}
+
+export function toErrorResponse(error: unknown): WorkerResponse {
+ return {
+ ok: false,
+ submissionTerminalFailure:
+ error instanceof HttpStatusError
+ ? error.submissionTerminalFailure
+ : undefined,
+ error:
+ error instanceof Error ? error.message : "Unexpected extension error.",
+ };
+}
diff --git a/apps/browser-extension/src/safari/background.ts b/apps/browser-extension/src/safari/background.ts
new file mode 100644
index 00000000..b5cf7aa3
--- /dev/null
+++ b/apps/browser-extension/src/safari/background.ts
@@ -0,0 +1,196 @@
+import {
+ isSafariRequest,
+ isWorkerRequest,
+ type DispatchAgent,
+ type OverlayInitData,
+ type SafariRequest,
+ type WorkerResponse,
+} from "../types";
+import {
+ getConnection,
+ handleWorkerRequest,
+ toErrorResponse,
+ type PairingExchangeResponse,
+ type PairingStartResponse,
+} from "../lib/worker-core";
+import { api } from "../lib/extension-api";
+import { buildSafariDeviceName } from "../lib/device-name";
+import {
+ loadRememberedAgentId,
+ rememberAgentSelection,
+} from "../lib/agent-memory";
+import { PairingSession, type PendingPairing } from "./pairing-session";
+import { ArmError, OverlaySession } from "./overlay-session";
+
+function buildName(os: string, suffix: string): string {
+ // Worker contexts lack maxTouchPoints; default to touch-capable so a
+ // desktop-mode iPad UA ("Macintosh" + os "ios") still labels as iPadOS.
+ const maxTouchPoints =
+ (navigator as Navigator & { maxTouchPoints?: number }).maxTouchPoints ?? 2;
+ return buildSafariDeviceName(os, navigator.userAgent, maxTouchPoints, suffix);
+}
+
+const storage = {
+ get: (key: string) => api.storage.local.get(key),
+ set: (items: Record) => api.storage.local.set(items),
+ remove: (key: string) => api.storage.local.remove(key),
+};
+
+const pairingSession = new PairingSession({
+ storage,
+ exchange: async (pending: PendingPairing) => {
+ const response = await handleWorkerRequest(
+ {
+ type: "pairing:exchange",
+ baseUrl: pending.baseUrl,
+ pairingId: pending.pairingId,
+ pairingSecret: pending.pairingSecret,
+ },
+ buildName
+ );
+ return response.data as PairingExchangeResponse;
+ },
+ disconnect: async () => {
+ await handleWorkerRequest({ type: "connection:disconnect" }, buildName);
+ },
+});
+
+const overlaySession = new OverlaySession();
+
+function verificationUrl(
+ pairing: PairingStartResponse,
+ baseUrl: string
+): string {
+ const url = new URL(pairing.verificationPath, baseUrl);
+ if (url.origin !== new URL(baseUrl).origin) {
+ throw new Error("Dispatch returned an unexpected verification address.");
+ }
+ return url.href;
+}
+
+async function handleSafariRequest(
+ request: SafariRequest,
+ sender: chrome.runtime.MessageSender
+): Promise {
+ switch (request.type) {
+ case "pairing:begin": {
+ const started = await handleWorkerRequest(
+ { type: "pairing:start", baseUrl: request.baseUrl },
+ buildName
+ );
+ const pairing = started.data as PairingStartResponse & {
+ baseUrl: string;
+ };
+ const pending: PendingPairing = {
+ baseUrl: pairing.baseUrl,
+ pairingId: pairing.pairingId,
+ pairingSecret: pairing.pairingSecret,
+ code: pairing.code,
+ expiresAt: pairing.expiresAt,
+ };
+ await pairingSession.begin(pending);
+ return {
+ ok: true,
+ data: {
+ code: pairing.code,
+ expiresAt: pairing.expiresAt,
+ verificationUrl: verificationUrl(pairing, pairing.baseUrl),
+ },
+ };
+ }
+ case "pairing:status": {
+ return { ok: true, data: await pairingSession.status() };
+ }
+ case "pairing:cancel": {
+ await pairingSession.cancel();
+ return { ok: true, data: {} };
+ }
+ case "picker:arm": {
+ await overlaySession.arm();
+ return { ok: true, data: {} };
+ }
+ case "picker:disarm": {
+ await overlaySession.disarm({ cleanup: true });
+ return { ok: true, data: {} };
+ }
+ case "overlay:init": {
+ const connection = await getConnection();
+ if (!connection) {
+ const data: OverlayInitData = {
+ connected: false,
+ agents: [],
+ selectedAgentId: null,
+ };
+ return { ok: true, data };
+ }
+ const listed = await handleWorkerRequest(
+ { type: "agents:list" },
+ buildName
+ );
+ const agents = (listed.data as { agents: DispatchAgent[] }).agents;
+ const remembered = await loadRememberedAgentId(
+ storage,
+ connection.baseUrl,
+ request.origin
+ );
+ const data: OverlayInitData = {
+ connected: true,
+ baseUrl: connection.baseUrl,
+ agents,
+ selectedAgentId:
+ remembered && agents.some((agent) => agent.id === remembered)
+ ? remembered
+ : null,
+ };
+ return { ok: true, data };
+ }
+ case "agent:remember": {
+ const connection = await getConnection();
+ if (connection) {
+ await rememberAgentSelection(
+ storage,
+ connection.baseUrl,
+ request.origin,
+ request.agentId
+ );
+ }
+ return { ok: true, data: {} };
+ }
+ case "overlay:closed": {
+ await overlaySession.handleOverlayClosed(sender.tab?.id);
+ return { ok: true, data: {} };
+ }
+ }
+}
+
+api.runtime.onMessage.addListener((request: unknown, sender, sendResponse) => {
+ if (isWorkerRequest(request)) {
+ void handleWorkerRequest(request, buildName)
+ .then(sendResponse)
+ .catch((error: unknown) => sendResponse(toErrorResponse(error)));
+ return true;
+ }
+ if (isSafariRequest(request)) {
+ void handleSafariRequest(request, sender)
+ .then(sendResponse)
+ .catch((error: unknown) => {
+ const response = toErrorResponse(error);
+ if (error instanceof ArmError) response.code = error.code;
+ sendResponse(response);
+ });
+ return true;
+ }
+ return false;
+});
+
+api.tabs.onUpdated.addListener((tabId, changeInfo) => {
+ if (changeInfo.status === "loading") void overlaySession.handleTabGone(tabId);
+});
+
+api.tabs.onRemoved.addListener((tabId) => {
+ void overlaySession.handleTabGone(tabId);
+});
+
+api.tabs.onActivated.addListener(({ tabId }) => {
+ void overlaySession.handleTabActivated(tabId);
+});
diff --git a/apps/browser-extension/src/safari/overlay-session.ts b/apps/browser-extension/src/safari/overlay-session.ts
new file mode 100644
index 00000000..10bc9e1a
--- /dev/null
+++ b/apps/browser-extension/src/safari/overlay-session.ts
@@ -0,0 +1,155 @@
+import { api } from "../lib/extension-api";
+import { classifyPickerPage } from "../lib/picker-access";
+import type { ArmFailureCode } from "../types";
+
+const OVERLAY_FILE = "feedback-overlay.js";
+const ARMED_TAB_KEY = "dispatchArmedTabId";
+const INJECT_ATTEMPTS = 4;
+const INJECT_RETRY_MS = 150;
+
+export class ArmError extends Error {
+ constructor(
+ readonly code: ArmFailureCode,
+ message: string
+ ) {
+ super(message);
+ }
+}
+
+function overlayIsReady(): boolean {
+ return Boolean(
+ window.__dispatchElementPickerCleanup &&
+ document.querySelector("[data-dispatch-feedback-host]")
+ );
+}
+
+function cleanupOverlay(): void {
+ window.__dispatchElementPickerCleanup?.();
+}
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+/**
+ * Tracks which tab has the feedback overlay injected. The armed tab id is
+ * mirrored into session storage so a restarted background worker can still
+ * clean up. Safari-only; top frame only (no iframe selection in v1).
+ */
+export class OverlaySession {
+ private armedTabId: number | null = null;
+
+ private get sessionStorage(): chrome.storage.StorageArea {
+ return api.storage.session ?? api.storage.local;
+ }
+
+ async arm(): Promise {
+ const [tab] = await api.tabs.query({ active: true, currentWindow: true });
+ if (!tab?.id) {
+ throw new ArmError("inject-failed", "No active tab is available.");
+ }
+ if (classifyPickerPage(tab.url) !== "ready") {
+ throw new ArmError(
+ "unsupported-page",
+ "This page cannot be inspected. Open a regular http(s) page and try again."
+ );
+ }
+
+ await this.disarm({ cleanup: true }).catch(() => undefined);
+
+ for (let attempt = 1; attempt <= INJECT_ATTEMPTS; attempt += 1) {
+ try {
+ await api.scripting.executeScript({
+ target: { tabId: tab.id },
+ files: [OVERLAY_FILE],
+ });
+ } catch {
+ // Safari rejects injection when the extension has no access to the
+ // site; the fix lives in Safari's own per-site settings.
+ throw new ArmError(
+ "no-site-access",
+ "Dispatch Feedback is not allowed on this website yet."
+ );
+ }
+ const ready = await this.probe(tab.id);
+ if (ready) {
+ await this.setArmedTab(tab.id);
+ return;
+ }
+ await this.cleanupInTab(tab.id);
+ await delay(INJECT_RETRY_MS);
+ }
+ throw new ArmError(
+ "inject-failed",
+ "The element selector could not start on this page. Reload the page and try again."
+ );
+ }
+
+ async disarm(options: { cleanup: boolean }): Promise {
+ const tabId = await this.getArmedTab();
+ if (tabId === null) return;
+ await this.setArmedTab(null);
+ if (options.cleanup) await this.cleanupInTab(tabId);
+ }
+
+ /** The overlay tore itself down (submitted/cancelled/failed). */
+ async handleOverlayClosed(senderTabId: number | undefined): Promise {
+ const tabId = await this.getArmedTab();
+ if (
+ tabId !== null &&
+ (senderTabId === undefined || senderTabId === tabId)
+ ) {
+ await this.setArmedTab(null);
+ }
+ }
+
+ /** The armed tab started navigating or closed; the overlay died with it. */
+ async handleTabGone(tabId: number): Promise {
+ if ((await this.getArmedTab()) === tabId) {
+ await this.setArmedTab(null);
+ }
+ }
+
+ /** The user switched tabs; remove the overlay so nothing lingers. */
+ async handleTabActivated(activeTabId: number): Promise {
+ const tabId = await this.getArmedTab();
+ if (tabId !== null && tabId !== activeTabId) {
+ await this.setArmedTab(null);
+ await this.cleanupInTab(tabId);
+ }
+ }
+
+ private async probe(tabId: number): Promise {
+ try {
+ const results = await api.scripting.executeScript({
+ target: { tabId },
+ func: overlayIsReady,
+ });
+ return results.some((result) => result.result === true);
+ } catch {
+ return false;
+ }
+ }
+
+ private async cleanupInTab(tabId: number): Promise {
+ await api.scripting
+ .executeScript({ target: { tabId }, func: cleanupOverlay })
+ .catch(() => undefined);
+ }
+
+ private async getArmedTab(): Promise {
+ if (this.armedTabId !== null) return this.armedTabId;
+ const stored = await this.sessionStorage.get(ARMED_TAB_KEY);
+ const tabId = stored[ARMED_TAB_KEY];
+ return typeof tabId === "number" ? tabId : null;
+ }
+
+ private async setArmedTab(tabId: number | null): Promise {
+ this.armedTabId = tabId;
+ if (tabId === null) {
+ await this.sessionStorage.remove(ARMED_TAB_KEY);
+ } else {
+ await this.sessionStorage.set({ [ARMED_TAB_KEY]: tabId });
+ }
+ }
+}
diff --git a/apps/browser-extension/src/safari/overlay/aim-layer.ts b/apps/browser-extension/src/safari/overlay/aim-layer.ts
new file mode 100644
index 00000000..6847892d
--- /dev/null
+++ b/apps/browser-extension/src/safari/overlay/aim-layer.ts
@@ -0,0 +1,117 @@
+/**
+ * Aiming-phase input handling. While active, page clicks are swallowed so a
+ * tap selects instead of activating links and buttons — but touch scrolling
+ * is left alone so the user can still reach the element. Events that pass
+ * through the overlay host (the toolbar's own buttons) are exempt.
+ */
+
+const TAP_MAX_MOVEMENT_PX = 12;
+const TAP_MAX_DURATION_MS = 700;
+
+export interface AimCallbacks {
+ /** A tap (or non-touch click) committed this element as the target. */
+ onTargetCommitted(target: Element): void;
+ /** A non-touch pointer is hovering this element (trackpad/mouse preview). */
+ onHover(target: Element): void;
+ onCancel(): void;
+}
+
+function pathElement(event: Event, host: Element): Element | null {
+ for (const entry of event.composedPath()) {
+ if (!(entry instanceof Element)) continue;
+ if (entry === host || host.contains(entry)) return null;
+ return entry;
+ }
+ return null;
+}
+
+function eventIsInHost(event: Event, host: Element): boolean {
+ return event
+ .composedPath()
+ .some((entry) => entry instanceof Element && entry === host);
+}
+
+export function startAiming(
+ host: Element,
+ callbacks: AimCallbacks
+): () => void {
+ let pointerStart: { x: number; y: number; time: number } | null = null;
+
+ function block(event: Event): void {
+ if (eventIsInHost(event, host)) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ }
+
+ function blockPropagationOnly(event: Event): void {
+ if (eventIsInHost(event, host)) return;
+ event.stopImmediatePropagation();
+ }
+
+ function handlePointerDown(event: PointerEvent): void {
+ if (eventIsInHost(event, host)) return;
+ event.stopImmediatePropagation();
+ pointerStart = { x: event.clientX, y: event.clientY, time: Date.now() };
+ }
+
+ function handlePointerUp(event: PointerEvent): void {
+ if (eventIsInHost(event, host)) return;
+ event.stopImmediatePropagation();
+ const start = pointerStart;
+ pointerStart = null;
+ if (!start) return;
+ const movement = Math.hypot(
+ event.clientX - start.x,
+ event.clientY - start.y
+ );
+ const duration = Date.now() - start.time;
+ // Anything longer or farther was a scroll or drag, not a tap.
+ if (movement > TAP_MAX_MOVEMENT_PX || duration > TAP_MAX_DURATION_MS) {
+ return;
+ }
+ const target =
+ pathElement(event, host) ??
+ document.elementFromPoint(event.clientX, event.clientY);
+ if (target && !(target === host || host.contains(target))) {
+ callbacks.onTargetCommitted(target);
+ }
+ }
+
+ function handlePointerMove(event: PointerEvent): void {
+ if (event.pointerType === "touch") return;
+ const target = pathElement(event, host);
+ if (target) callbacks.onHover(target);
+ }
+
+ function handleKeydown(event: KeyboardEvent): void {
+ if (event.key !== "Escape") return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ callbacks.onCancel();
+ }
+
+ window.addEventListener("pointerdown", handlePointerDown, true);
+ window.addEventListener("pointerup", handlePointerUp, true);
+ window.addEventListener("pointermove", handlePointerMove, true);
+ window.addEventListener("pointercancel", blockPropagationOnly, true);
+ window.addEventListener("mousedown", block, true);
+ window.addEventListener("mouseup", block, true);
+ window.addEventListener("click", block, true);
+ window.addEventListener("touchend", block, {
+ capture: true,
+ passive: false,
+ });
+ window.addEventListener("keydown", handleKeydown, true);
+
+ return () => {
+ window.removeEventListener("pointerdown", handlePointerDown, true);
+ window.removeEventListener("pointerup", handlePointerUp, true);
+ window.removeEventListener("pointermove", handlePointerMove, true);
+ window.removeEventListener("pointercancel", blockPropagationOnly, true);
+ window.removeEventListener("mousedown", block, true);
+ window.removeEventListener("mouseup", block, true);
+ window.removeEventListener("click", block, true);
+ window.removeEventListener("touchend", block, true);
+ window.removeEventListener("keydown", handleKeydown, true);
+ };
+}
diff --git a/apps/browser-extension/src/safari/overlay/card.ts b/apps/browser-extension/src/safari/overlay/card.ts
new file mode 100644
index 00000000..c29229f6
--- /dev/null
+++ b/apps/browser-extension/src/safari/overlay/card.ts
@@ -0,0 +1,275 @@
+import type {
+ BrowserSelection,
+ OverlayInitData,
+ SafariRequest,
+ WorkerRequest,
+} from "../../types";
+
+const COMMENT_MAX_LENGTH = 10_000;
+const SENT_DISMISS_MS = 1_200;
+
+export interface CardDeps {
+ /** Rejects with an Error whose `terminal` property is true when a retry
+ * must use a fresh clientSubmissionId. */
+ send(request: WorkerRequest | SafariRequest): Promise;
+ origin: string;
+ selection: BrowserSelection;
+ selectorLabel: string;
+ onReselect(): void;
+ onCancel(): void;
+ onSubmitted(): void;
+}
+
+interface CardState {
+ loading: boolean;
+ connected: boolean;
+ agents: OverlayInitData["agents"];
+ selectedAgentId: string;
+ comment: string;
+ busy: boolean;
+ sent: boolean;
+ error: string | null;
+}
+
+export interface CardHandle {
+ destroy(): void;
+}
+
+export function mountCard(container: HTMLElement, deps: CardDeps): CardHandle {
+ let destroyed = false;
+ let sentTimer: number | null = null;
+ let pendingSubmission: {
+ id: string;
+ agentId: string;
+ comment: string;
+ selection: BrowserSelection;
+ } | null = null;
+
+ const state: CardState = {
+ loading: true,
+ connected: false,
+ agents: [],
+ selectedAgentId: "",
+ comment: "",
+ busy: false,
+ sent: false,
+ error: null,
+ };
+
+ async function init(): Promise {
+ try {
+ const data = await deps.send({
+ type: "overlay:init",
+ origin: deps.origin,
+ });
+ if (destroyed) return;
+ state.loading = false;
+ state.connected = data.connected;
+ state.agents = data.agents;
+ state.selectedAgentId = data.selectedAgentId ?? data.agents[0]?.id ?? "";
+ } catch (error) {
+ if (destroyed) return;
+ state.loading = false;
+ state.connected = false;
+ state.error =
+ error instanceof Error ? error.message : "Extension request failed.";
+ }
+ render();
+ }
+
+ async function submit(): Promise {
+ const comment = state.comment.trim();
+ if (!comment || !state.selectedAgentId || state.busy) return;
+ if (
+ !pendingSubmission ||
+ pendingSubmission.agentId !== state.selectedAgentId ||
+ pendingSubmission.comment !== comment ||
+ pendingSubmission.selection !== deps.selection
+ ) {
+ pendingSubmission = {
+ id: crypto.randomUUID(),
+ agentId: state.selectedAgentId,
+ comment,
+ selection: deps.selection,
+ };
+ }
+ state.busy = true;
+ state.error = null;
+ render();
+ try {
+ await deps.send({
+ type: "submission:create",
+ clientSubmissionId: pendingSubmission.id,
+ agentId: pendingSubmission.agentId,
+ comment: pendingSubmission.comment,
+ selection: pendingSubmission.selection,
+ });
+ if (destroyed) return;
+ pendingSubmission = null;
+ state.busy = false;
+ state.sent = true;
+ render();
+ sentTimer = window.setTimeout(() => {
+ deps.onSubmitted();
+ }, SENT_DISMISS_MS);
+ } catch (error) {
+ if (destroyed) return;
+ if (
+ error instanceof Error &&
+ "terminal" in error &&
+ (error as Error & { terminal?: boolean }).terminal
+ ) {
+ pendingSubmission = null;
+ }
+ state.busy = false;
+ state.error =
+ error instanceof Error ? error.message : "Feedback could not be sent.";
+ render();
+ }
+ }
+
+ function render(): void {
+ container.replaceChildren();
+ container.classList.add("card");
+
+ if (state.sent) {
+ const sent = document.createElement("p");
+ sent.className = "card-sent";
+ sent.textContent = "Sent ✓";
+ container.append(sent);
+ return;
+ }
+
+ const summary = document.createElement("div");
+ summary.className = "card-summary";
+ const selector = document.createElement("code");
+ selector.className = "card-selector";
+ selector.textContent = deps.selectorLabel;
+ const reselect = document.createElement("button");
+ reselect.type = "button";
+ reselect.className = "card-button subtle-button";
+ reselect.textContent = "Reselect";
+ reselect.disabled = state.busy;
+ reselect.addEventListener("click", deps.onReselect);
+ summary.append(selector, reselect);
+ container.append(summary);
+
+ if (state.loading) {
+ const loading = document.createElement("p");
+ loading.className = "card-note";
+ loading.textContent = "Loading agents…";
+ container.append(loading);
+ return;
+ }
+
+ if (!state.connected) {
+ const note = document.createElement("p");
+ note.className = "card-note";
+ note.textContent =
+ state.error ??
+ "Not connected to Dispatch. Open the Dispatch Feedback extension to connect.";
+ const cancel = document.createElement("button");
+ cancel.type = "button";
+ cancel.className = "card-button";
+ cancel.textContent = "Close";
+ cancel.addEventListener("click", deps.onCancel);
+ container.append(note, cancel);
+ return;
+ }
+
+ const form = document.createElement("form");
+ form.className = "card-form";
+ form.addEventListener("submit", (event) => {
+ event.preventDefault();
+ void submit();
+ });
+
+ const agentLabel = document.createElement("label");
+ agentLabel.className = "card-label";
+ agentLabel.textContent = "Agent";
+ const agentSelect = document.createElement("select");
+ agentSelect.className = "card-select";
+ agentSelect.disabled = state.busy || state.agents.length === 0;
+ if (state.agents.length === 0) {
+ const option = document.createElement("option");
+ option.value = "";
+ option.textContent = "No running agents";
+ agentSelect.append(option);
+ }
+ for (const agent of state.agents) {
+ const option = document.createElement("option");
+ option.value = agent.id;
+ option.textContent = agent.repoName
+ ? `${agent.name} · ${agent.repoName}`
+ : agent.name;
+ option.selected = agent.id === state.selectedAgentId;
+ agentSelect.append(option);
+ }
+ agentSelect.addEventListener("change", () => {
+ state.selectedAgentId = agentSelect.value;
+ void deps
+ .send({
+ type: "agent:remember",
+ origin: deps.origin,
+ agentId: agentSelect.value,
+ })
+ .catch(() => undefined);
+ });
+ agentLabel.append(agentSelect);
+
+ const commentLabel = document.createElement("label");
+ commentLabel.className = "card-label";
+ commentLabel.textContent = "Comment";
+ const textarea = document.createElement("textarea");
+ textarea.className = "card-textarea";
+ textarea.maxLength = COMMENT_MAX_LENGTH;
+ textarea.placeholder = "What should the agent know about this element?";
+ textarea.value = state.comment;
+ textarea.disabled = state.busy;
+ textarea.addEventListener("input", () => {
+ state.comment = textarea.value;
+ updateSendEnabled();
+ });
+ commentLabel.append(textarea);
+
+ const errorSlot = document.createElement("p");
+ errorSlot.className = "card-error";
+ if (state.error) errorSlot.textContent = state.error;
+ errorSlot.hidden = !state.error;
+
+ const actions = document.createElement("div");
+ actions.className = "card-actions";
+ const cancel = document.createElement("button");
+ cancel.type = "button";
+ cancel.className = "card-button";
+ cancel.textContent = "Cancel";
+ cancel.disabled = state.busy;
+ cancel.addEventListener("click", deps.onCancel);
+ const send = document.createElement("button");
+ send.type = "submit";
+ send.className = "card-button primary-button";
+ send.textContent = state.busy ? "Sending…" : "Send";
+ actions.append(cancel, send);
+
+ function updateSendEnabled(): void {
+ send.disabled =
+ state.busy || !state.comment.trim() || !state.selectedAgentId;
+ }
+ updateSendEnabled();
+
+ form.append(agentLabel, commentLabel, errorSlot, actions);
+ container.append(form);
+ if (!state.busy) textarea.focus({ preventScroll: true });
+ }
+
+ render();
+ void init();
+
+ return {
+ destroy(): void {
+ destroyed = true;
+ if (sentTimer !== null) window.clearTimeout(sentTimer);
+ container.replaceChildren();
+ },
+ };
+}
diff --git a/apps/browser-extension/src/safari/overlay/index.ts b/apps/browser-extension/src/safari/overlay/index.ts
new file mode 100644
index 00000000..0d8af7dd
--- /dev/null
+++ b/apps/browser-extension/src/safari/overlay/index.ts
@@ -0,0 +1,443 @@
+import {
+ buildSelector,
+ createBrowserSelection,
+} from "../../lib/element-context";
+import { api } from "../../lib/extension-api";
+import type {
+ BrowserSelection,
+ SafariRequest,
+ WorkerRequest,
+ WorkerResponse,
+} from "../../types";
+import { startAiming } from "./aim-layer";
+import {
+ ascend,
+ canAscend,
+ canDescend,
+ createRefineState,
+ descend,
+ type RefineState,
+} from "./refine";
+import { mountCard, type CardHandle } from "./card";
+import {
+ bottomAnchoredTop,
+ centeredLeft,
+ clampToViewport,
+ onViewportChange,
+ readViewportMetrics,
+} from "./viewport";
+
+declare global {
+ interface Window {
+ __dispatchElementPickerCleanup?: () => void;
+ }
+}
+
+window.__dispatchElementPickerCleanup?.();
+
+const STYLES = `
+:host {
+ all: initial;
+}
+* {
+ box-sizing: border-box;
+}
+.highlight {
+ position: fixed;
+ z-index: 1;
+ pointer-events: none;
+ border: 2px solid #7c3aed;
+ background: rgba(124, 58, 237, 0.12);
+ border-radius: 3px;
+ display: none;
+}
+.badge {
+ position: fixed;
+ z-index: 2;
+ pointer-events: none;
+ display: none;
+ max-width: min(560px, calc(100vw - 16px));
+ overflow: hidden;
+ padding: 5px 8px;
+ border-radius: 4px;
+ color: #ffffff;
+ background: #6d28d9;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
+ font: 12px/1.35 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.hint,
+.toolbar,
+.card {
+ position: fixed;
+ z-index: 3;
+ pointer-events: auto;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
+}
+.hint {
+ padding: 10px 16px;
+ border-radius: 999px;
+ background: #171717;
+ color: #e7e5e4;
+ font-size: 14px;
+ font-weight: 600;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
+ border: 1px solid #44403c;
+}
+.toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px;
+ border-radius: 14px;
+ background: #171717;
+ border: 1px solid #44403c;
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
+ max-width: calc(100vw - 16px);
+}
+.toolbar-selector {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 200px;
+ color: #d6d3d1;
+ font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
+ padding: 0 4px;
+}
+.toolbar-button,
+.card-button {
+ min-height: 44px;
+ min-width: 44px;
+ padding: 8px 12px;
+ border: 1px solid #44403c;
+ border-radius: 10px;
+ background: #262626;
+ color: #e7e5e4;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+}
+.toolbar-button:disabled,
+.card-button:disabled {
+ opacity: 0.45;
+ cursor: default;
+}
+.primary-button {
+ border-color: #7c3aed;
+ background: #7c3aed;
+ color: #ffffff;
+}
+.subtle-button {
+ min-height: 34px;
+ padding: 4px 10px;
+ font-size: 12px;
+}
+.card {
+ display: grid;
+ gap: 10px;
+ width: min(560px, calc(100vw - 16px));
+ max-height: 45vh;
+ overflow-y: auto;
+ padding: 14px;
+ padding-bottom: calc(14px + env(safe-area-inset-bottom));
+ border-radius: 16px;
+ background: #171717;
+ border: 1px solid #44403c;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
+ color: #e7e5e4;
+ font-size: 14px;
+}
+.card-summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+.card-selector {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: #ddd6fe;
+ font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+.card-form {
+ display: grid;
+ gap: 10px;
+}
+.card-label {
+ display: grid;
+ gap: 6px;
+ color: #d6d3d1;
+ font-size: 12px;
+ font-weight: 600;
+}
+.card-select,
+.card-textarea {
+ width: 100%;
+ padding: 10px;
+ border: 1px solid #44403c;
+ border-radius: 10px;
+ background: #262626;
+ color: #e7e5e4;
+ font: inherit;
+}
+.card-textarea {
+ min-height: 72px;
+ resize: vertical;
+}
+.card-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+.card-error {
+ margin: 0;
+ color: #fca5a5;
+ font-size: 13px;
+}
+.card-note {
+ margin: 0;
+ color: #a8a29e;
+}
+.card-sent {
+ margin: 0;
+ text-align: center;
+ font-size: 16px;
+ font-weight: 700;
+ color: #bbf7d0;
+}
+`;
+
+class OverlayRequestError extends Error {
+ constructor(
+ message: string,
+ readonly terminal: boolean
+ ) {
+ super(message);
+ }
+}
+
+async function send(request: WorkerRequest | SafariRequest): Promise {
+ const response = (await api.runtime.sendMessage(
+ request
+ )) as WorkerResponse;
+ if (!response?.ok) {
+ throw new OverlayRequestError(
+ response?.error ?? "Extension request failed.",
+ response?.submissionTerminalFailure === true
+ );
+ }
+ return response.data as T;
+}
+
+const host = document.createElement("div");
+host.setAttribute("data-dispatch-feedback-host", "");
+Object.assign(host.style, {
+ position: "fixed",
+ inset: "0",
+ zIndex: "2147483647",
+ pointerEvents: "none",
+});
+const shadow = host.attachShadow({ mode: "open" });
+const styleElement = document.createElement("style");
+styleElement.textContent = STYLES;
+
+const highlight = document.createElement("div");
+highlight.className = "highlight";
+const badge = document.createElement("div");
+badge.className = "badge";
+const hint = document.createElement("div");
+hint.className = "hint";
+hint.textContent = "Tap an element to select it";
+const toolbar = document.createElement("div");
+toolbar.className = "toolbar";
+toolbar.style.display = "none";
+const cardContainer = document.createElement("div");
+cardContainer.style.display = "none";
+
+shadow.append(styleElement, highlight, badge, hint, toolbar, cardContainer);
+document.documentElement.append(host);
+
+let refine: RefineState | null = null;
+let hoverTarget: Element | null = null;
+let selection: BrowserSelection | null = null;
+let card: CardHandle | null = null;
+let stopAiming: (() => void) | null = null;
+let stopViewportTracking: (() => void) | null = null;
+let closed = false;
+
+function displayedTarget(): Element | null {
+ const target = refine?.current ?? hoverTarget;
+ return target?.isConnected ? target : null;
+}
+
+function positionFloating(element: HTMLElement): void {
+ const metrics = readViewportMetrics(window);
+ const height = element.offsetHeight;
+ const width = element.offsetWidth;
+ element.style.top = `${bottomAnchoredTop(metrics, height, 16)}px`;
+ element.style.left = `${centeredLeft(metrics, width)}px`;
+}
+
+function positionUI(): void {
+ const target = displayedTarget();
+ if (target) {
+ const rect = target.getBoundingClientRect();
+ Object.assign(highlight.style, {
+ display: "block",
+ left: `${rect.left}px`,
+ top: `${rect.top}px`,
+ width: `${rect.width}px`,
+ height: `${rect.height}px`,
+ });
+ const metrics = readViewportMetrics(window);
+ badge.style.display = "block";
+ const badgeTop =
+ rect.top >= 34
+ ? rect.top - 30
+ : Math.min(rect.bottom + 6, metrics.offsetTop + metrics.height - 30);
+ badge.style.left = `${clampToViewport(metrics, rect.left, Math.min(badge.offsetWidth, 240), "x")}px`;
+ badge.style.top = `${badgeTop}px`;
+ } else {
+ highlight.style.display = "none";
+ badge.style.display = "none";
+ }
+ if (hint.style.display !== "none") positionFloating(hint);
+ if (toolbar.style.display !== "none") positionFloating(toolbar);
+ if (cardContainer.style.display !== "none") positionFloating(cardContainer);
+}
+
+function renderToolbar(): void {
+ if (!refine) {
+ toolbar.style.display = "none";
+ hint.style.display = "";
+ positionUI();
+ return;
+ }
+ hint.style.display = "none";
+ toolbar.style.display = "flex";
+ toolbar.replaceChildren();
+
+ const selector = document.createElement("span");
+ selector.className = "toolbar-selector";
+ selector.textContent = buildSelector(refine.current);
+
+ const parentButton = document.createElement("button");
+ parentButton.type = "button";
+ parentButton.className = "toolbar-button";
+ parentButton.textContent = "‹ Parent";
+ parentButton.disabled = !canAscend(refine);
+ parentButton.addEventListener("click", () => {
+ if (refine) refine = ascend(refine);
+ renderToolbar();
+ });
+
+ const childButton = document.createElement("button");
+ childButton.type = "button";
+ childButton.className = "toolbar-button";
+ childButton.textContent = "Child ›";
+ childButton.disabled = !canDescend(refine);
+ childButton.addEventListener("click", () => {
+ if (refine) refine = descend(refine);
+ renderToolbar();
+ });
+
+ const cancelButton = document.createElement("button");
+ cancelButton.type = "button";
+ cancelButton.className = "toolbar-button";
+ cancelButton.textContent = "✕";
+ cancelButton.setAttribute("aria-label", "Cancel selection");
+ cancelButton.addEventListener("click", () => closeOverlay("cancelled"));
+
+ const useButton = document.createElement("button");
+ useButton.type = "button";
+ useButton.className = "toolbar-button primary-button";
+ useButton.textContent = "Use ✓";
+ useButton.addEventListener("click", confirmSelection);
+
+ toolbar.append(parentButton, childButton, selector, cancelButton, useButton);
+ positionUI();
+}
+
+function beginAiming(): void {
+ cardContainer.style.display = "none";
+ card?.destroy();
+ card = null;
+ selection = null;
+ stopAiming?.();
+ stopAiming = startAiming(host, {
+ onTargetCommitted(target) {
+ hoverTarget = null;
+ refine = createRefineState(target);
+ renderToolbar();
+ },
+ onHover(target) {
+ if (refine) return;
+ hoverTarget = target;
+ positionUI();
+ },
+ onCancel() {
+ closeOverlay("cancelled");
+ },
+ });
+ renderToolbar();
+}
+
+function confirmSelection(): void {
+ if (!refine?.current.isConnected) return;
+ try {
+ selection = createBrowserSelection(refine.current);
+ } catch {
+ closeOverlay("failed");
+ return;
+ }
+ stopAiming?.();
+ stopAiming = null;
+ hint.style.display = "none";
+ toolbar.style.display = "none";
+ badge.style.display = "none";
+ cardContainer.style.display = "";
+ card = mountCard(cardContainer, {
+ send,
+ origin: window.location.origin,
+ selection,
+ selectorLabel: buildSelector(refine.current),
+ onReselect: beginAiming,
+ onCancel: () => closeOverlay("cancelled"),
+ onSubmitted: () => closeOverlay("submitted"),
+ });
+ positionUI();
+}
+
+function cleanup(): void {
+ stopAiming?.();
+ stopAiming = null;
+ stopViewportTracking?.();
+ stopViewportTracking = null;
+ resizeObserver.disconnect();
+ card?.destroy();
+ card = null;
+ refine = null;
+ hoverTarget = null;
+ host.remove();
+ delete window.__dispatchElementPickerCleanup;
+}
+
+function closeOverlay(reason: "submitted" | "cancelled" | "failed"): void {
+ if (closed) return;
+ closed = true;
+ cleanup();
+ void send({ type: "overlay:closed", reason }).catch(() => undefined);
+}
+
+stopViewportTracking = onViewportChange(window, positionUI);
+// Re-anchor the floating UI when its own content changes size (agent list
+// loading in, error rows appearing) — bottom anchoring uses measured height.
+const resizeObserver = new ResizeObserver(() => positionUI());
+resizeObserver.observe(hint);
+resizeObserver.observe(toolbar);
+resizeObserver.observe(cardContainer);
+window.addEventListener("pagehide", cleanup, { once: true });
+window.__dispatchElementPickerCleanup = cleanup;
+beginAiming();
diff --git a/apps/browser-extension/src/safari/overlay/refine.test.ts b/apps/browser-extension/src/safari/overlay/refine.test.ts
new file mode 100644
index 00000000..5534cf94
--- /dev/null
+++ b/apps/browser-extension/src/safari/overlay/refine.test.ts
@@ -0,0 +1,91 @@
+// @vitest-environment happy-dom
+import { beforeEach, describe, expect, it } from "vitest";
+
+import {
+ ascend,
+ canAscend,
+ canDescend,
+ createRefineState,
+ descend,
+} from "./refine";
+
+describe("refine", () => {
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+
+
+ `;
+ });
+
+ function el(id: string): Element {
+ const found = document.getElementById(id);
+ if (!found) throw new Error(`missing #${id}`);
+ return found;
+ }
+
+ it("ascends to the parent and remembers the trail", () => {
+ let state = createRefineState(el("button"));
+ state = ascend(state);
+ expect(state.current).toBe(el("article"));
+ state = ascend(state);
+ expect(state.current).toBe(el("section"));
+ expect(state.descendTrail).toEqual([el("button"), el("article")]);
+ });
+
+ it("descends back along the exact trail the user came up", () => {
+ let state = createRefineState(el("button"));
+ state = ascend(state);
+ state = ascend(state);
+ state = descend(state);
+ expect(state.current).toBe(el("article"));
+ state = descend(state);
+ expect(state.current).toBe(el("button"));
+ expect(state.descendTrail).toEqual([]);
+ });
+
+ it("falls back to the first element child without a trail", () => {
+ let state = createRefineState(el("main"));
+ expect(canDescend(state)).toBe(true);
+ state = descend(state);
+ expect(state.current).toBe(el("section"));
+ });
+
+ it("stops ascending at body", () => {
+ let state = createRefineState(el("main"));
+ expect(canAscend(state)).toBe(true);
+ state = ascend(state);
+ expect(state.current).toBe(document.body);
+ expect(canAscend(state)).toBe(false);
+ expect(ascend(state)).toBe(state);
+ });
+
+ it("cannot descend into a leaf", () => {
+ const state = createRefineState(el("button"));
+ expect(canDescend(state)).toBe(false);
+ expect(descend(state)).toBe(state);
+ });
+
+ it("a new tap resets the trail", () => {
+ let state = createRefineState(el("button"));
+ state = ascend(state);
+ state = createRefineState(el("section"));
+ expect(state.descendTrail).toEqual([]);
+ });
+
+ it("skips the overlay host when descending", () => {
+ const hostParent = document.createElement("div");
+ const host = document.createElement("div");
+ host.setAttribute("data-dispatch-feedback-host", "");
+ hostParent.append(host);
+ document.body.append(hostParent);
+
+ const state = createRefineState(hostParent);
+ expect(canDescend(state)).toBe(false);
+ expect(descend(state)).toBe(state);
+ });
+});
diff --git a/apps/browser-extension/src/safari/overlay/refine.ts b/apps/browser-extension/src/safari/overlay/refine.ts
new file mode 100644
index 00000000..314de0e9
--- /dev/null
+++ b/apps/browser-extension/src/safari/overlay/refine.ts
@@ -0,0 +1,53 @@
+/**
+ * Tap + refine target selection. A tap picks the initial element; the
+ * refinement toolbar walks the target up to a parent or back down. Descending
+ * retraces the exact path the user ascended (the trail) before falling back
+ * to the first element child.
+ */
+
+export interface RefineState {
+ current: Element;
+ descendTrail: Element[];
+}
+
+function isOverlayElement(element: Element): boolean {
+ return element.hasAttribute("data-dispatch-feedback-host");
+}
+
+export function createRefineState(target: Element): RefineState {
+ return { current: target, descendTrail: [] };
+}
+
+export function canAscend(state: RefineState): boolean {
+ const parent = state.current.parentElement;
+ return parent !== null && parent.tagName !== "HTML";
+}
+
+export function canDescend(state: RefineState): boolean {
+ const trailTop = state.descendTrail[state.descendTrail.length - 1];
+ if (trailTop && trailTop.parentElement === state.current) return true;
+ const child = state.current.firstElementChild;
+ return child !== null && !isOverlayElement(child);
+}
+
+export function ascend(state: RefineState): RefineState {
+ const parent = state.current.parentElement;
+ if (!parent || parent.tagName === "HTML") return state;
+ return {
+ current: parent,
+ descendTrail: [...state.descendTrail, state.current],
+ };
+}
+
+export function descend(state: RefineState): RefineState {
+ const trailTop = state.descendTrail[state.descendTrail.length - 1];
+ if (trailTop && trailTop.parentElement === state.current) {
+ return {
+ current: trailTop,
+ descendTrail: state.descendTrail.slice(0, -1),
+ };
+ }
+ const child = state.current.firstElementChild;
+ if (!child || isOverlayElement(child)) return state;
+ return { current: child, descendTrail: [] };
+}
diff --git a/apps/browser-extension/src/safari/overlay/viewport.test.ts b/apps/browser-extension/src/safari/overlay/viewport.test.ts
new file mode 100644
index 00000000..b1c1d44a
--- /dev/null
+++ b/apps/browser-extension/src/safari/overlay/viewport.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ bottomAnchoredTop,
+ centeredLeft,
+ clampToViewport,
+ type VisualViewportMetrics,
+} from "./viewport";
+
+const unscrolled: VisualViewportMetrics = {
+ offsetTop: 0,
+ offsetLeft: 0,
+ width: 800,
+ height: 600,
+};
+
+// Pinch-zoomed / keyboard-open: the visual viewport sits inside the layout one.
+const shifted: VisualViewportMetrics = {
+ offsetTop: 120,
+ offsetLeft: 40,
+ width: 400,
+ height: 300,
+};
+
+describe("viewport", () => {
+ it("anchors to the bottom of the unshifted viewport", () => {
+ expect(bottomAnchoredTop(unscrolled, 50, 16)).toBe(600 - 50 - 16);
+ });
+
+ it("follows the visual viewport when zoomed or the keyboard is open", () => {
+ expect(bottomAnchoredTop(shifted, 50, 16)).toBe(120 + 300 - 50 - 16);
+ });
+
+ it("centers within the visual viewport, not the layout viewport", () => {
+ expect(centeredLeft(shifted, 200)).toBe(40 + (400 - 200) / 2);
+ expect(centeredLeft(shifted, 500)).toBe(40);
+ });
+
+ it("clamps positions into the visible area", () => {
+ expect(clampToViewport(shifted, 0, 100, "x")).toBe(48);
+ expect(clampToViewport(shifted, 1000, 100, "x")).toBe(40 + 400 - 100 - 8);
+ expect(clampToViewport(shifted, 200, 100, "y")).toBe(200);
+ });
+});
diff --git a/apps/browser-extension/src/safari/overlay/viewport.ts b/apps/browser-extension/src/safari/overlay/viewport.ts
new file mode 100644
index 00000000..a7175dcf
--- /dev/null
+++ b/apps/browser-extension/src/safari/overlay/viewport.ts
@@ -0,0 +1,80 @@
+/**
+ * Fixed-position elements are laid out in the layout viewport, but on iPad the
+ * visual viewport moves independently (pinch zoom, on-screen keyboard). These
+ * helpers convert "bottom of what the user can currently see" into layout
+ * coordinates so the toolbar and card stay on screen.
+ */
+
+export interface VisualViewportMetrics {
+ offsetTop: number;
+ offsetLeft: number;
+ width: number;
+ height: number;
+}
+
+export function readViewportMetrics(win: Window): VisualViewportMetrics {
+ const vv = win.visualViewport;
+ if (vv) {
+ return {
+ offsetTop: vv.offsetTop,
+ offsetLeft: vv.offsetLeft,
+ width: vv.width,
+ height: vv.height,
+ };
+ }
+ return {
+ offsetTop: 0,
+ offsetLeft: 0,
+ width: win.innerWidth,
+ height: win.innerHeight,
+ };
+}
+
+/** Layout-coordinate `top` that pins an element's bottom edge to the visual viewport bottom. */
+export function bottomAnchoredTop(
+ metrics: VisualViewportMetrics,
+ elementHeight: number,
+ margin: number
+): number {
+ return metrics.offsetTop + metrics.height - elementHeight - margin;
+}
+
+/** Layout-coordinate `left` that horizontally centers an element in the visual viewport. */
+export function centeredLeft(
+ metrics: VisualViewportMetrics,
+ elementWidth: number
+): number {
+ return metrics.offsetLeft + Math.max(0, (metrics.width - elementWidth) / 2);
+}
+
+/** Clamp a badge/tooltip position into the visible viewport. */
+export function clampToViewport(
+ metrics: VisualViewportMetrics,
+ value: number,
+ size: number,
+ axis: "x" | "y",
+ margin = 8
+): number {
+ const start =
+ (axis === "x" ? metrics.offsetLeft : metrics.offsetTop) + margin;
+ const end =
+ (axis === "x"
+ ? metrics.offsetLeft + metrics.width
+ : metrics.offsetTop + metrics.height) -
+ size -
+ margin;
+ return Math.max(start, Math.min(value, Math.max(start, end)));
+}
+
+export function onViewportChange(win: Window, handler: () => void): () => void {
+ win.addEventListener("scroll", handler, true);
+ win.addEventListener("resize", handler);
+ win.visualViewport?.addEventListener("resize", handler);
+ win.visualViewport?.addEventListener("scroll", handler);
+ return () => {
+ win.removeEventListener("scroll", handler, true);
+ win.removeEventListener("resize", handler);
+ win.visualViewport?.removeEventListener("resize", handler);
+ win.visualViewport?.removeEventListener("scroll", handler);
+ };
+}
diff --git a/apps/browser-extension/src/safari/pairing-session.test.ts b/apps/browser-extension/src/safari/pairing-session.test.ts
new file mode 100644
index 00000000..48e96931
--- /dev/null
+++ b/apps/browser-extension/src/safari/pairing-session.test.ts
@@ -0,0 +1,186 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ PairingSession,
+ PENDING_PAIRING_KEY,
+ type PairingSessionStorage,
+ type PendingPairing,
+} from "./pairing-session";
+
+function createStorage(): PairingSessionStorage & {
+ values: Record;
+} {
+ const values: Record = {};
+ return {
+ values,
+ get: (key) => Promise.resolve(key in values ? { [key]: values[key] } : {}),
+ set: (items) => {
+ Object.assign(values, items);
+ return Promise.resolve();
+ },
+ remove: (key) => {
+ delete values[key];
+ return Promise.resolve();
+ },
+ };
+}
+
+function createPending(expiresInMs = 60_000): PendingPairing {
+ return {
+ baseUrl: "http://dispatch.test",
+ pairingId: "pairing-1",
+ pairingSecret: "secret-1",
+ code: "123456",
+ expiresAt: new Date(Date.now() + expiresInMs).toISOString(),
+ };
+}
+
+describe("PairingSession", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("polls until the exchange approves and reports approved once", async () => {
+ const storage = createStorage();
+ const exchange = vi
+ .fn<() => Promise<{ status: "pending" | "approved" }>>()
+ .mockResolvedValueOnce({ status: "pending" })
+ .mockResolvedValueOnce({ status: "approved" });
+ const disconnect = vi.fn(() => Promise.resolve());
+ const session = new PairingSession({ storage, exchange, disconnect });
+
+ await session.begin(createPending());
+ expect(storage.values[PENDING_PAIRING_KEY]).toMatchObject({
+ kind: "pending",
+ });
+
+ await vi.advanceTimersByTimeAsync(2_500);
+ expect(exchange).toHaveBeenCalledTimes(1);
+ expect(storage.values[PENDING_PAIRING_KEY]).toMatchObject({
+ kind: "pending",
+ });
+
+ await vi.advanceTimersByTimeAsync(2_500);
+ expect(exchange).toHaveBeenCalledTimes(2);
+ expect(storage.values[PENDING_PAIRING_KEY]).toMatchObject({
+ kind: "approved",
+ });
+
+ await expect(session.status()).resolves.toEqual({
+ state: "approved",
+ baseUrl: "http://dispatch.test",
+ });
+ await expect(session.status()).resolves.toEqual({ state: "idle" });
+ expect(disconnect).not.toHaveBeenCalled();
+ });
+
+ it("resumes a persisted pairing after a background restart via status()", async () => {
+ const storage = createStorage();
+ const pending = createPending();
+ storage.values[PENDING_PAIRING_KEY] = { kind: "pending", pending };
+
+ const exchange = vi
+ .fn<() => Promise<{ status: "pending" | "approved" }>>()
+ .mockResolvedValue({ status: "approved" });
+ const session = new PairingSession({
+ storage,
+ exchange,
+ disconnect: () => Promise.resolve(),
+ });
+
+ await expect(session.status()).resolves.toEqual({
+ state: "approved",
+ baseUrl: "http://dispatch.test",
+ });
+ expect(exchange).toHaveBeenCalledTimes(1);
+ expect(storage.values[PENDING_PAIRING_KEY]).toBeUndefined();
+ });
+
+ it("keeps polling after status() while the pairing stays pending", async () => {
+ const storage = createStorage();
+ const pending = createPending();
+ storage.values[PENDING_PAIRING_KEY] = { kind: "pending", pending };
+
+ const exchange = vi
+ .fn<() => Promise<{ status: "pending" | "approved" }>>()
+ .mockResolvedValue({ status: "pending" });
+ const session = new PairingSession({
+ storage,
+ exchange,
+ disconnect: () => Promise.resolve(),
+ });
+
+ await expect(session.status()).resolves.toMatchObject({
+ state: "pending",
+ code: "123456",
+ });
+ await vi.advanceTimersByTimeAsync(5_000);
+ expect(exchange.mock.calls.length).toBeGreaterThanOrEqual(3);
+ });
+
+ it("expires a stale pairing", async () => {
+ const storage = createStorage();
+ storage.values[PENDING_PAIRING_KEY] = {
+ kind: "pending",
+ pending: createPending(-1_000),
+ };
+ const exchange = vi.fn<() => Promise<{ status: "pending" | "approved" }>>();
+ const session = new PairingSession({
+ storage,
+ exchange,
+ disconnect: () => Promise.resolve(),
+ });
+
+ await expect(session.status()).resolves.toEqual({ state: "expired" });
+ expect(exchange).not.toHaveBeenCalled();
+ expect(storage.values[PENDING_PAIRING_KEY]).toBeUndefined();
+ });
+
+ it("disconnects when a cancel races a late approval", async () => {
+ const storage = createStorage();
+ const resolvers: Array<
+ (value: { status: "pending" | "approved" }) => void
+ > = [];
+ const exchange = vi.fn(
+ () =>
+ new Promise<{ status: "pending" | "approved" }>((resolve) => {
+ resolvers.push(resolve);
+ })
+ );
+ const disconnect = vi.fn(() => Promise.resolve());
+ const session = new PairingSession({ storage, exchange, disconnect });
+
+ await session.begin(createPending());
+ await vi.advanceTimersByTimeAsync(2_500);
+ expect(exchange).toHaveBeenCalledTimes(1);
+
+ const cancelled = session.cancel();
+ resolvers[0]?.({ status: "approved" });
+ await cancelled;
+
+ expect(disconnect).toHaveBeenCalledTimes(1);
+ expect(storage.values[PENDING_PAIRING_KEY]).toBeUndefined();
+ await expect(session.status()).resolves.toEqual({ state: "idle" });
+ });
+
+ it("cancel without a late approval leaves the connection alone", async () => {
+ const storage = createStorage();
+ const exchange = vi
+ .fn<() => Promise<{ status: "pending" | "approved" }>>()
+ .mockResolvedValue({ status: "pending" });
+ const disconnect = vi.fn(() => Promise.resolve());
+ const session = new PairingSession({ storage, exchange, disconnect });
+
+ await session.begin(createPending());
+ await vi.advanceTimersByTimeAsync(2_500);
+ await session.cancel();
+
+ expect(disconnect).not.toHaveBeenCalled();
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect(exchange).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/browser-extension/src/safari/pairing-session.ts b/apps/browser-extension/src/safari/pairing-session.ts
new file mode 100644
index 00000000..14651f13
--- /dev/null
+++ b/apps/browser-extension/src/safari/pairing-session.ts
@@ -0,0 +1,161 @@
+import type { PairingSessionState } from "../types";
+
+export const PENDING_PAIRING_KEY = "dispatchPendingPairing";
+const POLL_INTERVAL_MS = 2_500;
+
+export interface PendingPairing {
+ baseUrl: string;
+ pairingId: string;
+ pairingSecret: string;
+ code: string;
+ expiresAt: string;
+}
+
+type StoredPairingRecord =
+ | { kind: "pending"; pending: PendingPairing }
+ | { kind: "approved"; baseUrl: string };
+
+export interface PairingSessionStorage {
+ get(key: string): Promise>;
+ set(items: Record): Promise;
+ remove(key: string): Promise;
+}
+
+export interface PairingSessionDeps {
+ storage: PairingSessionStorage;
+ exchange(
+ pending: PendingPairing
+ ): Promise<{ status: "pending" | "approved" }>;
+ disconnect(): Promise;
+}
+
+type ExchangeOutcome = "pending" | "approved" | "error";
+
+/**
+ * Owns the pairing poll loop. On Safari the popup is destroyed the moment the
+ * verification tab opens, so unlike the Chrome side panel the poll must live
+ * in the background — and because the background worker itself can be killed,
+ * the pending pairing is persisted and `status()` performs an immediate
+ * exchange so a reopened popup resumes (or completes) pairing in one call.
+ */
+export class PairingSession {
+ private timer: ReturnType | null = null;
+ private inFlight: Promise | null = null;
+ private epoch = 0;
+
+ constructor(private readonly deps: PairingSessionDeps) {}
+
+ async begin(pending: PendingPairing): Promise {
+ this.epoch += 1;
+ this.stopTimer();
+ await this.deps.storage.set({
+ [PENDING_PAIRING_KEY]: {
+ kind: "pending",
+ pending,
+ } satisfies StoredPairingRecord,
+ });
+ this.schedule(pending, this.epoch);
+ }
+
+ async status(): Promise {
+ const record = await this.readRecord();
+ if (!record) return { state: "idle" };
+ if (record.kind === "approved") {
+ await this.deps.storage.remove(PENDING_PAIRING_KEY);
+ return { state: "approved", baseUrl: record.baseUrl };
+ }
+ const pending = record.pending;
+ if (this.isExpired(pending)) {
+ this.epoch += 1;
+ this.stopTimer();
+ await this.deps.storage.remove(PENDING_PAIRING_KEY);
+ return { state: "expired" };
+ }
+ const epoch = this.epoch;
+ const outcome = await this.attemptExchange(pending, epoch);
+ if (outcome === "approved") {
+ await this.deps.storage.remove(PENDING_PAIRING_KEY);
+ return { state: "approved", baseUrl: pending.baseUrl };
+ }
+ if (epoch === this.epoch) this.schedule(pending, epoch);
+ return {
+ state: "pending",
+ baseUrl: pending.baseUrl,
+ code: pending.code,
+ expiresAt: pending.expiresAt,
+ };
+ }
+
+ async cancel(): Promise {
+ this.epoch += 1;
+ this.stopTimer();
+ const inFlight = this.inFlight;
+ await this.deps.storage.remove(PENDING_PAIRING_KEY);
+ if (inFlight && (await inFlight) === "approved") {
+ // The late approval already stored a connection; revoke it, matching
+ // the Chrome side panel's cancel semantics.
+ await this.deps.disconnect().catch(() => undefined);
+ }
+ }
+
+ private async readRecord(): Promise {
+ const stored = await this.deps.storage.get(PENDING_PAIRING_KEY);
+ return (
+ (stored[PENDING_PAIRING_KEY] as StoredPairingRecord | undefined) ?? null
+ );
+ }
+
+ private isExpired(pending: PendingPairing): boolean {
+ const expiresAt = Date.parse(pending.expiresAt);
+ return Number.isNaN(expiresAt) || expiresAt <= Date.now();
+ }
+
+ private schedule(pending: PendingPairing, epoch: number): void {
+ this.stopTimer();
+ this.timer = setTimeout(() => {
+ this.timer = null;
+ void this.poll(pending, epoch);
+ }, POLL_INTERVAL_MS);
+ }
+
+ private async poll(pending: PendingPairing, epoch: number): Promise {
+ if (epoch !== this.epoch) return;
+ if (this.isExpired(pending)) {
+ await this.deps.storage.remove(PENDING_PAIRING_KEY);
+ return;
+ }
+ const outcome = await this.attemptExchange(pending, epoch);
+ if (epoch !== this.epoch) return;
+ if (outcome !== "approved") this.schedule(pending, epoch);
+ }
+
+ private async attemptExchange(
+ pending: PendingPairing,
+ epoch: number
+ ): Promise {
+ const attempt: Promise = this.deps
+ .exchange(pending)
+ .then((result) => result.status)
+ .catch(() => "error" as const);
+ this.inFlight = attempt;
+ const outcome = await attempt;
+ if (this.inFlight === attempt) this.inFlight = null;
+ if (outcome === "approved" && epoch === this.epoch) {
+ this.stopTimer();
+ await this.deps.storage.set({
+ [PENDING_PAIRING_KEY]: {
+ kind: "approved",
+ baseUrl: pending.baseUrl,
+ } satisfies StoredPairingRecord,
+ });
+ }
+ return outcome;
+ }
+
+ private stopTimer(): void {
+ if (this.timer !== null) {
+ clearTimeout(this.timer);
+ this.timer = null;
+ }
+ }
+}
diff --git a/apps/browser-extension/src/safari/popup-state.test.ts b/apps/browser-extension/src/safari/popup-state.test.ts
new file mode 100644
index 00000000..ff1fb074
--- /dev/null
+++ b/apps/browser-extension/src/safari/popup-state.test.ts
@@ -0,0 +1,160 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ initialPopupState,
+ reducePopupState,
+ type PopupEvent,
+ type PopupState,
+} from "./popup-state";
+
+function reduceAll(events: PopupEvent[], from: PopupState = initialPopupState) {
+ return events.reduce(reducePopupState, from);
+}
+
+describe("reducePopupState", () => {
+ it("lands on disconnected when nothing is pending or connected", () => {
+ const state = reduceAll([
+ {
+ type: "status",
+ pairing: { state: "idle" },
+ connection: { connected: false },
+ },
+ ]);
+ expect(state.view).toBe("disconnected");
+ });
+
+ it("resumes a pending pairing on reopen — the popup died when the verification tab opened", () => {
+ const state = reduceAll([
+ {
+ type: "status",
+ pairing: {
+ state: "pending",
+ baseUrl: "http://dispatch.test",
+ code: "123456",
+ expiresAt: "2099-01-01T00:00:00.000Z",
+ },
+ connection: { connected: false },
+ },
+ ]);
+ expect(state).toMatchObject({ view: "pairing", code: "123456" });
+ });
+
+ it("shows connected with a success notice when approval happened while closed", () => {
+ const state = reduceAll([
+ {
+ type: "status",
+ pairing: { state: "approved", baseUrl: "http://dispatch.test" },
+ connection: { connected: true, baseUrl: "http://dispatch.test" },
+ },
+ ]);
+ expect(state).toMatchObject({ view: "connected", justPaired: true });
+ });
+
+ it("surfaces pairing expiry as an error on the connect form", () => {
+ const state = reduceAll([
+ {
+ type: "status",
+ pairing: { state: "expired" },
+ connection: { connected: false },
+ },
+ ]);
+ expect(state).toMatchObject({
+ view: "disconnected",
+ error: "The pairing request expired. Try again.",
+ });
+ });
+
+ it("requires an explicit second connect for insecure HTTP", () => {
+ const disconnected = reduceAll([
+ {
+ type: "status",
+ pairing: { state: "idle" },
+ connection: { connected: false },
+ },
+ { type: "insecure-warning", baseUrl: "http://dispatch.test" },
+ ]);
+ expect(disconnected).toMatchObject({
+ view: "disconnected",
+ insecureWarning: true,
+ insecureAcknowledgedFor: "http://dispatch.test",
+ });
+ });
+
+ it("routes a no-site-access arm failure to the guidance view and back on retry", () => {
+ const connected: PopupState = {
+ view: "connected",
+ baseUrl: "http://dispatch.test",
+ justPaired: false,
+ arming: false,
+ error: null,
+ };
+ const denied = reduceAll(
+ [
+ { type: "arm-started" },
+ {
+ type: "arm-failed",
+ code: "no-site-access",
+ error: "Dispatch Feedback is not allowed on this website yet.",
+ },
+ ],
+ connected
+ );
+ expect(denied).toMatchObject({
+ view: "needs-site-access",
+ baseUrl: "http://dispatch.test",
+ });
+
+ const retried = reducePopupState(denied, { type: "site-access-retry" });
+ expect(retried).toMatchObject({ view: "connected", arming: false });
+ });
+
+ it("keeps other arm failures on the connected view with an error", () => {
+ const connected: PopupState = {
+ view: "connected",
+ baseUrl: "http://dispatch.test",
+ justPaired: false,
+ arming: true,
+ error: null,
+ };
+ const failed = reducePopupState(connected, {
+ type: "arm-failed",
+ code: "unsupported-page",
+ error: "This page cannot be inspected.",
+ });
+ expect(failed).toMatchObject({
+ view: "connected",
+ arming: false,
+ error: "This page cannot be inspected.",
+ });
+ });
+
+ it("does not let a background status refresh clobber an in-flight arm", () => {
+ const arming: PopupState = {
+ view: "connected",
+ baseUrl: "http://dispatch.test",
+ justPaired: false,
+ arming: true,
+ error: null,
+ };
+ const refreshed = reducePopupState(arming, {
+ type: "status",
+ pairing: { state: "idle" },
+ connection: { connected: true, baseUrl: "http://dispatch.test" },
+ });
+ expect(refreshed).toBe(arming);
+ });
+
+ it("moves to armed after a successful arm", () => {
+ const connected: PopupState = {
+ view: "connected",
+ baseUrl: "http://dispatch.test",
+ justPaired: false,
+ arming: true,
+ error: null,
+ };
+ expect(reducePopupState(connected, { type: "arm-succeeded" })).toEqual({
+ view: "armed",
+ baseUrl: "http://dispatch.test",
+ });
+ });
+});
diff --git a/apps/browser-extension/src/safari/popup-state.ts b/apps/browser-extension/src/safari/popup-state.ts
new file mode 100644
index 00000000..81547557
--- /dev/null
+++ b/apps/browser-extension/src/safari/popup-state.ts
@@ -0,0 +1,230 @@
+import type {
+ ArmFailureCode,
+ ConnectionStatus,
+ PairingSessionState,
+} from "../types";
+
+export type PopupState =
+ | { view: "loading" }
+ | {
+ view: "disconnected";
+ urlInput: string;
+ insecureAcknowledgedFor: string | null;
+ insecureWarning: boolean;
+ error: string | null;
+ busy: boolean;
+ }
+ | {
+ view: "pairing";
+ baseUrl: string;
+ code: string;
+ expiresAt: string;
+ error: string | null;
+ }
+ | {
+ view: "connected";
+ baseUrl: string;
+ justPaired: boolean;
+ arming: boolean;
+ error: string | null;
+ }
+ | { view: "armed"; baseUrl: string }
+ | {
+ view: "needs-site-access";
+ baseUrl: string;
+ message: string;
+ };
+
+export type PopupEvent =
+ | {
+ type: "status";
+ pairing: PairingSessionState;
+ connection: ConnectionStatus;
+ }
+ | { type: "status-failed"; error: string }
+ | { type: "url-input"; value: string }
+ | { type: "connect-invalid"; error: string }
+ | { type: "insecure-warning"; baseUrl: string }
+ | { type: "connect-started" }
+ | {
+ type: "pairing-started";
+ baseUrl: string;
+ code: string;
+ expiresAt: string;
+ }
+ | { type: "pairing-failed"; error: string }
+ | { type: "pairing-cancelled" }
+ | { type: "disconnected" }
+ | { type: "arm-started" }
+ | { type: "arm-succeeded" }
+ | { type: "arm-failed"; code: ArmFailureCode | undefined; error: string }
+ | { type: "site-access-retry" };
+
+export const initialPopupState: PopupState = { view: "loading" };
+
+function disconnected(
+ overrides: Partial> = {}
+): PopupState {
+ return {
+ view: "disconnected",
+ urlInput: "",
+ insecureAcknowledgedFor: null,
+ insecureWarning: false,
+ error: null,
+ busy: false,
+ ...overrides,
+ };
+}
+
+export function reducePopupState(
+ state: PopupState,
+ event: PopupEvent
+): PopupState {
+ switch (event.type) {
+ case "status": {
+ if (event.pairing.state === "pending") {
+ return {
+ view: "pairing",
+ baseUrl: event.pairing.baseUrl,
+ code: event.pairing.code,
+ expiresAt: event.pairing.expiresAt,
+ error: null,
+ };
+ }
+ if (event.pairing.state === "approved") {
+ return {
+ view: "connected",
+ baseUrl: event.pairing.baseUrl,
+ justPaired: true,
+ arming: false,
+ error: null,
+ };
+ }
+ if (event.connection.connected && event.connection.baseUrl) {
+ // Keep richer local context (arming, needs-site-access) when a
+ // periodic status refresh lands mid-flow.
+ if (
+ state.view === "connected" ||
+ state.view === "armed" ||
+ state.view === "needs-site-access"
+ ) {
+ return state;
+ }
+ return {
+ view: "connected",
+ baseUrl: event.connection.baseUrl,
+ justPaired: false,
+ arming: false,
+ error: null,
+ };
+ }
+ if (state.view === "disconnected") {
+ return event.pairing.state === "expired"
+ ? { ...state, error: "The pairing request expired. Try again." }
+ : state;
+ }
+ return disconnected({
+ error:
+ event.pairing.state === "expired"
+ ? "The pairing request expired. Try again."
+ : null,
+ });
+ }
+ case "status-failed": {
+ if (state.view === "loading") return disconnected({ error: event.error });
+ return state;
+ }
+ case "url-input": {
+ if (state.view !== "disconnected") return state;
+ return {
+ ...state,
+ urlInput: event.value,
+ insecureWarning: false,
+ error: null,
+ };
+ }
+ case "connect-invalid": {
+ if (state.view !== "disconnected") return state;
+ return { ...state, error: event.error, busy: false };
+ }
+ case "insecure-warning": {
+ if (state.view !== "disconnected") return state;
+ return {
+ ...state,
+ insecureWarning: true,
+ insecureAcknowledgedFor: event.baseUrl,
+ error: null,
+ };
+ }
+ case "connect-started": {
+ if (state.view !== "disconnected") return state;
+ return { ...state, busy: true, error: null, insecureWarning: false };
+ }
+ case "pairing-started": {
+ return {
+ view: "pairing",
+ baseUrl: event.baseUrl,
+ code: event.code,
+ expiresAt: event.expiresAt,
+ error: null,
+ };
+ }
+ case "pairing-failed": {
+ if (state.view === "disconnected") {
+ return { ...state, busy: false, error: event.error };
+ }
+ return disconnected({ error: event.error });
+ }
+ case "pairing-cancelled":
+ case "disconnected": {
+ return disconnected();
+ }
+ case "arm-started": {
+ if (state.view === "connected")
+ return { ...state, arming: true, error: null };
+ if (state.view === "needs-site-access") {
+ return {
+ view: "connected",
+ baseUrl: state.baseUrl,
+ justPaired: false,
+ arming: true,
+ error: null,
+ };
+ }
+ return state;
+ }
+ case "arm-succeeded": {
+ const baseUrl =
+ state.view === "connected" || state.view === "needs-site-access"
+ ? state.baseUrl
+ : "";
+ return { view: "armed", baseUrl };
+ }
+ case "arm-failed": {
+ const baseUrl =
+ state.view === "connected" || state.view === "needs-site-access"
+ ? state.baseUrl
+ : "";
+ if (event.code === "no-site-access") {
+ return { view: "needs-site-access", baseUrl, message: event.error };
+ }
+ return {
+ view: "connected",
+ baseUrl,
+ justPaired: false,
+ arming: false,
+ error: event.error,
+ };
+ }
+ case "site-access-retry": {
+ if (state.view !== "needs-site-access") return state;
+ return {
+ view: "connected",
+ baseUrl: state.baseUrl,
+ justPaired: false,
+ arming: false,
+ error: null,
+ };
+ }
+ }
+}
diff --git a/apps/browser-extension/src/safari/popup.css b/apps/browser-extension/src/safari/popup.css
new file mode 100644
index 00000000..f464e97c
--- /dev/null
+++ b/apps/browser-extension/src/safari/popup.css
@@ -0,0 +1,164 @@
+:root {
+ color: #e7e5e4;
+ background: #171717;
+ font-family:
+ Inter,
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ sans-serif;
+ font-size: 15px;
+ font-synthesis: none;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+}
+
+button,
+input {
+ font: inherit;
+ border: 1px solid #44403c;
+ border-radius: 8px;
+ color: inherit;
+ background: #262626;
+}
+
+button {
+ min-height: 44px;
+ padding: 10px 14px;
+ cursor: pointer;
+ font-weight: 600;
+}
+
+button:disabled {
+ cursor: default;
+ opacity: 0.55;
+}
+
+button.primary {
+ border-color: #7c3aed;
+ background: #7c3aed;
+ color: white;
+}
+
+input {
+ width: 100%;
+ padding: 11px 12px;
+}
+
+label {
+ display: grid;
+ gap: 6px;
+ color: #d6d3d1;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.shell {
+ display: grid;
+ gap: 14px;
+ padding: 16px;
+ padding-bottom: calc(16px + env(safe-area-inset-bottom));
+}
+
+.header h1 {
+ margin: 0;
+ font-size: 18px;
+}
+
+.subtle {
+ margin: 0;
+ color: #a8a29e;
+ font-size: 13px;
+}
+
+.status {
+ margin: 0;
+ padding: 10px 12px;
+ border-radius: 8px;
+ font-size: 13px;
+}
+
+.status.error {
+ border: 1px solid #7f1d1d;
+ background: #450a0a;
+ color: #fecaca;
+}
+
+.status.info {
+ border: 1px solid #44403c;
+ background: #262626;
+ color: #d6d3d1;
+}
+
+.status.success {
+ border: 1px solid #14532d;
+ background: #052e16;
+ color: #bbf7d0;
+}
+
+.code {
+ margin: 0;
+ padding: 14px;
+ border: 1px dashed #7c3aed;
+ border-radius: 10px;
+ text-align: center;
+ font:
+ 600 28px/1.2 ui-monospace,
+ SFMono-Regular,
+ Menlo,
+ monospace;
+ letter-spacing: 6px;
+ color: #ddd6fe;
+}
+
+.connection {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.connection .url {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font:
+ 500 13px ui-monospace,
+ SFMono-Regular,
+ Menlo,
+ monospace;
+ color: #d6d3d1;
+}
+
+.badge {
+ flex-shrink: 0;
+ padding: 2px 7px;
+ border: 1px solid #7f1d1d;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 700;
+ color: #fca5a5;
+}
+
+.steps {
+ margin: 0;
+ padding-left: 20px;
+ display: grid;
+ gap: 6px;
+ color: #d6d3d1;
+ font-size: 13px;
+}
+
+.actions {
+ display: grid;
+ gap: 10px;
+}
diff --git a/apps/browser-extension/src/safari/popup.ts b/apps/browser-extension/src/safari/popup.ts
new file mode 100644
index 00000000..7d3e8bdf
--- /dev/null
+++ b/apps/browser-extension/src/safari/popup.ts
@@ -0,0 +1,382 @@
+import "./popup.css";
+import {
+ type ArmFailureCode,
+ type ConnectionStatus,
+ type PairingSessionState,
+ type SafariRequest,
+ type WorkerRequest,
+ type WorkerResponse,
+} from "../types";
+import { api } from "../lib/extension-api";
+import { usesInsecureHttp } from "../lib/dispatch-url";
+import {
+ initialPopupState,
+ reducePopupState,
+ type PopupEvent,
+ type PopupState,
+} from "./popup-state";
+
+const PAIRING_REFRESH_MS = 2_000;
+
+const appElement = document.querySelector("#app");
+if (!appElement) throw new Error("Popup root is missing.");
+const app: HTMLElement = appElement;
+
+let state: PopupState = initialPopupState;
+let pairingRefreshTimer: number | null = null;
+
+class RequestFailure extends Error {
+ constructor(
+ message: string,
+ readonly code: ArmFailureCode | undefined
+ ) {
+ super(message);
+ }
+}
+
+async function send(request: WorkerRequest | SafariRequest): Promise {
+ const response = (await api.runtime.sendMessage(
+ request
+ )) as WorkerResponse;
+ if (!response?.ok) {
+ throw new RequestFailure(
+ response?.error ?? "Extension request failed.",
+ response?.code
+ );
+ }
+ return response.data as T;
+}
+
+function dispatch(event: PopupEvent): void {
+ state = reducePopupState(state, event);
+ render();
+}
+
+function normalizeUrlInput(input: string): URL {
+ const withProtocol = /^https?:\/\//i.test(input.trim())
+ ? input.trim()
+ : `http://${input.trim()}`;
+ return new URL(withProtocol);
+}
+
+async function refreshStatus(): Promise {
+ try {
+ const [pairing, connection] = await Promise.all([
+ send({ type: "pairing:status" }),
+ send({ type: "connection:status" }),
+ ]);
+ dispatch({ type: "status", pairing, connection });
+ } catch (error) {
+ dispatch({
+ type: "status-failed",
+ error:
+ error instanceof Error ? error.message : "Extension request failed.",
+ });
+ }
+}
+
+function syncPairingRefresh(): void {
+ const shouldPoll = state.view === "pairing";
+ if (shouldPoll && pairingRefreshTimer === null) {
+ pairingRefreshTimer = window.setInterval(() => {
+ void refreshStatus();
+ }, PAIRING_REFRESH_MS);
+ } else if (!shouldPoll && pairingRefreshTimer !== null) {
+ window.clearInterval(pairingRefreshTimer);
+ pairingRefreshTimer = null;
+ }
+}
+
+async function connect(input: string): Promise {
+ if (state.view !== "disconnected") return;
+ let baseUrl: string;
+ try {
+ baseUrl = normalizeUrlInput(input).origin;
+ } catch {
+ dispatch({ type: "connect-invalid", error: "Enter a valid Dispatch URL." });
+ return;
+ }
+ if (usesInsecureHttp(baseUrl) && state.insecureAcknowledgedFor !== baseUrl) {
+ dispatch({ type: "insecure-warning", baseUrl });
+ return;
+ }
+
+ dispatch({ type: "connect-started" });
+ // Safari may or may not surface a host-permission prompt here; a rejection
+ // is not fatal — the background's fetch is the real access test.
+ await api.permissions
+ .request({
+ origins: [`${new URL(baseUrl).protocol}//${new URL(baseUrl).hostname}/*`],
+ })
+ .catch(() => false);
+ try {
+ const pairing = await send<{
+ code: string;
+ expiresAt: string;
+ verificationUrl: string;
+ }>({ type: "pairing:begin", baseUrl });
+ dispatch({
+ type: "pairing-started",
+ baseUrl,
+ code: pairing.code,
+ expiresAt: pairing.expiresAt,
+ });
+ // Opening the approval tab dismisses this popup on iPad; the background
+ // keeps polling and a reopened popup resumes from pairing:status.
+ await api.tabs.create({ url: pairing.verificationUrl, active: true });
+ } catch (error) {
+ dispatch({
+ type: "pairing-failed",
+ error: error instanceof Error ? error.message : "Pairing failed.",
+ });
+ }
+}
+
+async function cancelPairing(): Promise {
+ await send({ type: "pairing:cancel" }).catch(() => undefined);
+ dispatch({ type: "pairing-cancelled" });
+}
+
+async function disconnect(): Promise {
+ await send({ type: "connection:disconnect" }).catch(() => undefined);
+ dispatch({ type: "disconnected" });
+}
+
+async function selectElement(): Promise {
+ dispatch({ type: "arm-started" });
+ try {
+ await send({ type: "picker:arm" });
+ dispatch({ type: "arm-succeeded" });
+ window.close();
+ } catch (error) {
+ dispatch({
+ type: "arm-failed",
+ code: error instanceof RequestFailure ? error.code : undefined,
+ error:
+ error instanceof Error
+ ? error.message
+ : "The element selector could not start.",
+ });
+ }
+}
+
+function createNotice(
+ kind: "error" | "info" | "success",
+ message: string
+): HTMLElement {
+ const notice = document.createElement("p");
+ notice.className = `status ${kind}`;
+ notice.setAttribute("role", kind === "error" ? "alert" : "status");
+ notice.textContent = message;
+ return notice;
+}
+
+function createShell(): HTMLElement {
+ const shell = document.createElement("section");
+ shell.className = "shell";
+ const header = document.createElement("header");
+ header.className = "header";
+ const title = document.createElement("h1");
+ title.textContent = "Dispatch feedback";
+ header.append(title);
+ shell.append(header);
+ return shell;
+}
+
+function renderLoading(shell: HTMLElement): void {
+ const message = document.createElement("p");
+ message.className = "subtle";
+ message.textContent = "Loading…";
+ shell.append(message);
+}
+
+function renderDisconnected(
+ shell: HTMLElement,
+ view: Extract
+): void {
+ const intro = document.createElement("p");
+ intro.className = "subtle";
+ intro.textContent =
+ "Connect to the Dispatch instance that manages your agents.";
+ shell.append(intro);
+
+ const form = document.createElement("form");
+ form.className = "actions";
+ const label = document.createElement("label");
+ label.textContent = "Dispatch URL";
+ const input = document.createElement("input");
+ input.type = "text";
+ input.inputMode = "url";
+ input.autocapitalize = "off";
+ input.autocomplete = "off";
+ input.spellcheck = false;
+ input.placeholder = "https://dispatch.example.com";
+ input.value = view.urlInput;
+ input.addEventListener("input", () => {
+ if (state.view === "disconnected") state.urlInput = input.value;
+ });
+ label.append(input);
+
+ const connectButton = document.createElement("button");
+ connectButton.type = "submit";
+ connectButton.className = "primary";
+ connectButton.disabled = view.busy;
+ connectButton.textContent = view.busy
+ ? "Connecting…"
+ : view.insecureWarning
+ ? "Connect anyway"
+ : "Connect";
+ form.addEventListener("submit", (event) => {
+ event.preventDefault();
+ void connect(input.value);
+ });
+ form.append(label, connectButton);
+
+ if (view.insecureWarning) {
+ shell.append(
+ createNotice(
+ "info",
+ "HTTP sends pairing credentials and feedback without encryption. Continue only if you trust this network."
+ )
+ );
+ }
+ if (view.error) shell.append(createNotice("error", view.error));
+ shell.append(form);
+}
+
+function renderPairing(
+ shell: HTMLElement,
+ view: Extract
+): void {
+ const code = document.createElement("p");
+ code.className = "code";
+ code.textContent = view.code;
+
+ const explain = document.createElement("p");
+ explain.className = "subtle";
+ explain.textContent = `Approve the connection in the Dispatch tab (${view.baseUrl}) only if it shows this same code. You can close this popup — pairing continues in the background.`;
+
+ const cancel = document.createElement("button");
+ cancel.type = "button";
+ cancel.textContent = "Cancel pairing";
+ cancel.addEventListener("click", () => void cancelPairing());
+
+ if (view.error) shell.append(createNotice("error", view.error));
+ shell.append(code, explain, cancel);
+}
+
+function renderConnected(
+ shell: HTMLElement,
+ view: Extract
+): void {
+ const connection = document.createElement("div");
+ connection.className = "connection";
+ const url = document.createElement("span");
+ url.className = "url";
+ url.textContent = view.baseUrl;
+ connection.append(url);
+ if (usesInsecureHttp(view.baseUrl)) {
+ const badge = document.createElement("span");
+ badge.className = "badge";
+ badge.textContent = "HTTP";
+ connection.append(badge);
+ }
+
+ if (view.justPaired) {
+ shell.append(createNotice("success", "Browser connected to Dispatch."));
+ }
+ if (view.error) shell.append(createNotice("error", view.error));
+
+ const actions = document.createElement("div");
+ actions.className = "actions";
+ const select = document.createElement("button");
+ select.type = "button";
+ select.className = "primary";
+ select.disabled = view.arming;
+ select.textContent = view.arming ? "Starting…" : "Select element";
+ select.addEventListener("click", () => void selectElement());
+ const disconnectButton = document.createElement("button");
+ disconnectButton.type = "button";
+ disconnectButton.textContent = "Disconnect";
+ disconnectButton.addEventListener("click", () => void disconnect());
+ actions.append(select, disconnectButton);
+
+ shell.append(connection, actions);
+}
+
+function renderArmed(shell: HTMLElement): void {
+ shell.append(
+ createNotice(
+ "success",
+ "Tap an element on the page to select it. Scrolling still works while you aim."
+ )
+ );
+}
+
+function renderNeedsSiteAccess(
+ shell: HTMLElement,
+ view: Extract
+): void {
+ shell.append(
+ createNotice("info", "Dispatch Feedback needs access to this website.")
+ );
+ const steps = document.createElement("ol");
+ steps.className = "steps";
+ for (const text of [
+ "Tap the extension (puzzle) button in Safari's address bar.",
+ "Choose Dispatch Feedback.",
+ "Allow it for this website (or always).",
+ "Come back here and try again.",
+ ]) {
+ const step = document.createElement("li");
+ step.textContent = text;
+ steps.append(step);
+ }
+ const hint = document.createElement("p");
+ hint.className = "subtle";
+ hint.textContent =
+ "You can also manage access in Settings → Apps → Safari → Extensions.";
+
+ const actions = document.createElement("div");
+ actions.className = "actions";
+ const retry = document.createElement("button");
+ retry.type = "button";
+ retry.className = "primary";
+ retry.textContent = "Try again";
+ retry.addEventListener("click", () => {
+ dispatch({ type: "site-access-retry" });
+ void selectElement();
+ });
+ actions.append(retry);
+
+ shell.append(steps, hint, actions);
+}
+
+function render(): void {
+ syncPairingRefresh();
+ const shell = createShell();
+ switch (state.view) {
+ case "loading":
+ renderLoading(shell);
+ break;
+ case "disconnected":
+ renderDisconnected(shell, state);
+ break;
+ case "pairing":
+ renderPairing(shell, state);
+ break;
+ case "connected":
+ renderConnected(shell, state);
+ break;
+ case "armed":
+ renderArmed(shell);
+ break;
+ case "needs-site-access":
+ renderNeedsSiteAccess(shell, state);
+ break;
+ }
+ app.replaceChildren(shell);
+}
+
+render();
+void refreshStatus();
diff --git a/apps/browser-extension/src/service-worker.ts b/apps/browser-extension/src/service-worker.ts
index 0fe6e355..3d0aba6e 100644
--- a/apps/browser-extension/src/service-worker.ts
+++ b/apps/browser-extension/src/service-worker.ts
@@ -1,36 +1,7 @@
-import {
- isWorkerRequest,
- type BrowserSelection,
- type ConnectionStatus,
- type DispatchAgent,
- type WorkerRequest,
- type WorkerResponse,
-} from "./types";
-import { normalizeDispatchBaseUrl } from "./lib/dispatch-url";
+import { isWorkerRequest } from "./types";
+import { handleWorkerRequest, toErrorResponse } from "./lib/worker-core";
import { buildDeviceName } from "./lib/device-name";
-const CONNECTION_KEY = "dispatchConnection";
-const DEVICE_NAME_KEY = "dispatchDeviceName";
-const REQUEST_TIMEOUT_MS = 15_000;
-
-interface StoredConnection {
- baseUrl: string;
- token: string;
-}
-
-interface PairingStartResponse {
- pairingId: string;
- pairingSecret: string;
- code: string;
- verificationPath: string;
- expiresAt: string;
-}
-
-interface PairingExchangeResponse {
- status: "pending" | "approved";
- token?: string;
-}
-
void chrome.sidePanel
.setPanelBehavior({ openPanelOnActionClick: true })
.catch(() => {
@@ -39,215 +10,14 @@ void chrome.sidePanel
void chrome.storage.local.setAccessLevel({ accessLevel: "TRUSTED_CONTEXTS" });
-class HttpStatusError extends Error {
- constructor(
- message: string,
- readonly status: number,
- readonly submissionTerminalFailure = false
- ) {
- super(message);
- }
-}
-
-async function getConnection(): Promise {
- const stored = await chrome.storage.local.get(CONNECTION_KEY);
- return (stored[CONNECTION_KEY] as StoredConnection | undefined) ?? null;
-}
-
-async function getDeviceName(): Promise {
- const stored = await chrome.storage.local.get(DEVICE_NAME_KEY);
- const existing = stored[DEVICE_NAME_KEY];
- if (typeof existing === "string" && existing.length > 0) return existing;
-
- const platform = await chrome.runtime.getPlatformInfo();
- const name = buildDeviceName(
- platform.os,
- crypto.randomUUID().replaceAll("-", "").slice(0, 4)
- );
- await chrome.storage.local.set({ [DEVICE_NAME_KEY]: name });
- return name;
-}
-
-async function fetchJson(
- url: string,
- init: RequestInit,
- expectedStatuses: number[] = [200]
-): Promise {
- let response: Response;
- try {
- response = await fetch(url, {
- ...init,
- redirect: "error",
- signal: init.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- });
- } catch (error) {
- if (
- error instanceof DOMException &&
- (error.name === "TimeoutError" || error.name === "AbortError")
- ) {
- throw new Error("Dispatch did not respond in time.");
- }
- throw error;
- }
- const body = (await response.json().catch(() => null)) as
- | (T & {
- message?: string;
- error?: string;
- status?: unknown;
- submissionId?: unknown;
- })
- | null;
- if (!expectedStatuses.includes(response.status)) {
- const message =
- body?.message ?? body?.error ?? `Dispatch returned ${response.status}.`;
- throw new HttpStatusError(
- message,
- response.status,
- body?.status === "failed" && typeof body.submissionId === "string"
- );
- }
- if (!body) throw new Error("Dispatch returned an empty response.");
- return body;
-}
-
-async function authenticatedFetch(
- path: string,
- init: RequestInit = {}
-): Promise {
- const connection = await getConnection();
- if (!connection) throw new Error("Connect this extension to Dispatch first.");
-
- try {
- return await fetchJson(`${connection.baseUrl}${path}`, {
- ...init,
- headers: {
- Authorization: `Bearer ${connection.token}`,
- "Content-Type": "application/json",
- ...init.headers,
- },
- });
- } catch (error) {
- if (error instanceof HttpStatusError && error.status === 401) {
- await chrome.storage.local.remove(CONNECTION_KEY);
- }
- throw error;
- }
-}
-
-async function handleRequest(request: WorkerRequest): Promise {
- switch (request.type) {
- case "connection:status": {
- const connection = await getConnection();
- const status: ConnectionStatus = connection
- ? { connected: true, baseUrl: connection.baseUrl }
- : { connected: false };
- return { ok: true, data: status };
- }
- case "connection:disconnect": {
- let revokedRemotely = true;
- try {
- await authenticatedFetch<{ ok: boolean }>(
- "/api/v1/browser-extension/token",
- { method: "DELETE" }
- );
- } catch {
- revokedRemotely = false;
- } finally {
- await chrome.storage.local.remove(CONNECTION_KEY);
- }
- return { ok: true, data: { revokedRemotely } };
- }
- case "pairing:start": {
- const baseUrl = normalizeDispatchBaseUrl(request.baseUrl);
- const deviceName = await getDeviceName();
- let pairing: PairingStartResponse;
- try {
- pairing = await fetchJson(
- `${baseUrl}/api/v1/auth/browser-extension/pairings`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ deviceName }),
- },
- [200, 201]
- );
- } catch (error) {
- if (error instanceof HttpStatusError && error.status === 404) {
- throw new Error(
- "This Dispatch instance does not support browser feedback pairing. Connect to the Dispatch instance managing your agent, not the web app you want to inspect."
- );
- }
- throw error;
- }
- return { ok: true, data: { ...pairing, baseUrl } };
- }
- case "pairing:exchange": {
- const baseUrl = normalizeDispatchBaseUrl(request.baseUrl);
- const result = await fetchJson(
- `${baseUrl}/api/v1/auth/browser-extension/pairings/${encodeURIComponent(request.pairingId)}/exchange`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ pairingSecret: request.pairingSecret }),
- }
- );
- if (result.status === "approved" && result.token) {
- await chrome.storage.local.set({
- [CONNECTION_KEY]: {
- baseUrl,
- token: result.token,
- } satisfies StoredConnection,
- });
- }
- return { ok: true, data: result };
- }
- case "agents:list": {
- const result = await authenticatedFetch<{ agents: DispatchAgent[] }>(
- "/api/v1/browser-extension/agents"
- );
- return { ok: true, data: result };
- }
- case "submission:create": {
- const body: {
- clientSubmissionId: string;
- agentId: string;
- comment: string;
- page: BrowserSelection["page"];
- element: BrowserSelection["element"];
- } = {
- clientSubmissionId: request.clientSubmissionId,
- agentId: request.agentId,
- comment: request.comment,
- page: request.selection.page,
- element: request.selection.element,
- };
- const result = await authenticatedFetch(
- "/api/v1/browser-extension/submissions",
- { method: "POST", body: JSON.stringify(body) }
- );
- return { ok: true, data: result };
- }
- }
-}
-
chrome.runtime.onMessage.addListener(
(request: unknown, _sender, sendResponse) => {
if (!isWorkerRequest(request)) return false;
- void handleRequest(request)
+ void handleWorkerRequest(request, buildDeviceName)
.then(sendResponse)
.catch((error: unknown) => {
- sendResponse({
- ok: false,
- submissionTerminalFailure:
- error instanceof HttpStatusError
- ? error.submissionTerminalFailure
- : undefined,
- error:
- error instanceof Error
- ? error.message
- : "Unexpected extension error.",
- } satisfies WorkerResponse);
+ sendResponse(toErrorResponse(error));
});
return true;
}
diff --git a/apps/browser-extension/src/types.test.ts b/apps/browser-extension/src/types.test.ts
index 9f5e512e..5d1ba3d6 100644
--- a/apps/browser-extension/src/types.test.ts
+++ b/apps/browser-extension/src/types.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { isWorkerRequest } from "./types";
+import { isSafariRequest, isWorkerRequest } from "./types";
describe("isWorkerRequest", () => {
it.each([
@@ -24,3 +24,28 @@ describe("isWorkerRequest", () => {
expect(isWorkerRequest(request)).toBe(false);
});
});
+
+describe("isSafariRequest", () => {
+ it.each([
+ "pairing:begin",
+ "pairing:status",
+ "pairing:cancel",
+ "picker:arm",
+ "picker:disarm",
+ "overlay:init",
+ "agent:remember",
+ "overlay:closed",
+ ])("accepts the %s request type", (type) => {
+ expect(isSafariRequest({ type })).toBe(true);
+ });
+
+ it.each([
+ null,
+ {},
+ { type: 1 },
+ { type: "pairing:start" },
+ { type: "overlay:unknown" },
+ ])("rejects an unsupported request: %j", (request) => {
+ expect(isSafariRequest(request)).toBe(false);
+ });
+});
diff --git a/apps/browser-extension/src/types.ts b/apps/browser-extension/src/types.ts
index 4ac4e607..c51aa173 100644
--- a/apps/browser-extension/src/types.ts
+++ b/apps/browser-extension/src/types.ts
@@ -101,6 +101,62 @@ export function isWorkerRequest(request: unknown): request is WorkerRequest {
);
}
+/**
+ * Safari-only requests handled by src/safari/background.ts. Kept as a separate
+ * union so the shared worker-core switch over WorkerRequest stays exhaustive.
+ */
+export type SafariRequest =
+ | { type: "pairing:begin"; baseUrl: string }
+ | { type: "pairing:status" }
+ | { type: "pairing:cancel" }
+ | { type: "picker:arm" }
+ | { type: "picker:disarm" }
+ | { type: "overlay:init"; origin: string }
+ | { type: "agent:remember"; origin: string; agentId: string }
+ | {
+ type: "overlay:closed";
+ reason: "submitted" | "cancelled" | "failed";
+ };
+
+const SAFARI_REQUEST_TYPES = {
+ "pairing:begin": true,
+ "pairing:status": true,
+ "pairing:cancel": true,
+ "picker:arm": true,
+ "picker:disarm": true,
+ "overlay:init": true,
+ "agent:remember": true,
+ "overlay:closed": true,
+} satisfies Record;
+
+export function isSafariRequest(request: unknown): request is SafariRequest {
+ return (
+ typeof request === "object" &&
+ request !== null &&
+ "type" in request &&
+ typeof request.type === "string" &&
+ Object.hasOwn(SAFARI_REQUEST_TYPES, request.type)
+ );
+}
+
+export type PairingSessionState =
+ | { state: "idle" }
+ | { state: "pending"; baseUrl: string; code: string; expiresAt: string }
+ | { state: "approved"; baseUrl: string }
+ | { state: "expired" };
+
+export type ArmFailureCode =
+ | "no-site-access"
+ | "unsupported-page"
+ | "inject-failed";
+
+export interface OverlayInitData {
+ connected: boolean;
+ baseUrl?: string;
+ agents: DispatchAgent[];
+ selectedAgentId: string | null;
+}
+
export interface ConnectionStatus {
connected: boolean;
baseUrl?: string;
@@ -111,4 +167,6 @@ export interface WorkerResponse {
data?: T;
error?: string;
submissionTerminalFailure?: boolean;
+ /** Set on failed Safari picker:arm responses to select the guidance shown. */
+ code?: ArmFailureCode;
}
diff --git a/apps/browser-extension/tsconfig.json b/apps/browser-extension/tsconfig.json
index 52146c45..56c98855 100644
--- a/apps/browser-extension/tsconfig.json
+++ b/apps/browser-extension/tsconfig.json
@@ -6,5 +6,12 @@
"moduleResolution": "Bundler",
"types": ["chrome", "vitest/globals"]
},
- "include": ["src/**/*.ts", "vite.config.ts", "vite.picker.config.ts"]
+ "include": [
+ "src/**/*.ts",
+ "vite.config.ts",
+ "vite.picker.config.ts",
+ "vite.safari.config.ts",
+ "vite.safari.background.config.ts",
+ "vite.safari.overlay.config.ts"
+ ]
}
diff --git a/apps/browser-extension/vite.safari.background.config.ts b/apps/browser-extension/vite.safari.background.config.ts
new file mode 100644
index 00000000..8a167bc8
--- /dev/null
+++ b/apps/browser-extension/vite.safari.background.config.ts
@@ -0,0 +1,20 @@
+import { resolve } from "node:path";
+import { defineConfig } from "vite";
+
+// The background ships as a single classic script: Safari's support for
+// module service workers is inconsistent, and an IIFE bundle removes the
+// question entirely.
+export default defineConfig({
+ publicDir: false,
+ build: {
+ outDir: "dist/safari/unpacked",
+ emptyOutDir: false,
+ lib: {
+ entry: resolve(import.meta.dirname, "src/safari/background.ts"),
+ name: "DispatchFeedbackBackground",
+ formats: ["iife"],
+ fileName: () => "background.js",
+ },
+ minify: false,
+ },
+});
diff --git a/apps/browser-extension/vite.safari.config.ts b/apps/browser-extension/vite.safari.config.ts
new file mode 100644
index 00000000..4382ffb6
--- /dev/null
+++ b/apps/browser-extension/vite.safari.config.ts
@@ -0,0 +1,41 @@
+import { copyFileSync, cpSync } from "node:fs";
+import { resolve } from "node:path";
+import { defineConfig, type Plugin } from "vite";
+
+// public/ holds the Chrome manifest, so the Safari build assembles its own
+// static files instead of using Vite's publicDir copy.
+function safariStaticFiles(): Plugin {
+ return {
+ name: "safari-static-files",
+ closeBundle() {
+ const outDir = resolve(import.meta.dirname, "dist/safari/unpacked");
+ copyFileSync(
+ resolve(import.meta.dirname, "manifest.safari.json"),
+ resolve(outDir, "manifest.json")
+ );
+ cpSync(
+ resolve(import.meta.dirname, "public/icons"),
+ resolve(outDir, "icons"),
+ { recursive: true }
+ );
+ },
+ };
+}
+
+export default defineConfig({
+ publicDir: false,
+ plugins: [safariStaticFiles()],
+ build: {
+ outDir: "dist/safari/unpacked",
+ rollupOptions: {
+ input: {
+ popup: resolve(import.meta.dirname, "popup.html"),
+ },
+ output: {
+ entryFileNames: "[name].js",
+ chunkFileNames: "assets/[name]-[hash].js",
+ assetFileNames: "assets/[name]-[hash][extname]",
+ },
+ },
+ },
+});
diff --git a/apps/browser-extension/vite.safari.overlay.config.ts b/apps/browser-extension/vite.safari.overlay.config.ts
new file mode 100644
index 00000000..5d9a395a
--- /dev/null
+++ b/apps/browser-extension/vite.safari.overlay.config.ts
@@ -0,0 +1,19 @@
+import { resolve } from "node:path";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ // public/ holds the Chrome manifest; without this the lib build would copy
+ // it over the Safari manifest.json already placed in the outDir.
+ publicDir: false,
+ build: {
+ outDir: "dist/safari/unpacked",
+ emptyOutDir: false,
+ lib: {
+ entry: resolve(import.meta.dirname, "src/safari/overlay/index.ts"),
+ name: "DispatchFeedbackOverlay",
+ formats: ["iife"],
+ fileName: () => "feedback-overlay.js",
+ },
+ minify: false,
+ },
+});
diff --git a/apps/web/src/components/app/browser-extension-settings.test.tsx b/apps/web/src/components/app/browser-extension-settings.test.tsx
index 5a9f3e55..f655b818 100644
--- a/apps/web/src/components/app/browser-extension-settings.test.tsx
+++ b/apps/web/src/components/app/browser-extension-settings.test.tsx
@@ -78,6 +78,33 @@ describe("BrowserExtensionSettings", () => {
).toBeTruthy();
});
+ it("switches the install guide to Safari on iPad instructions", async () => {
+ renderSettings();
+ expect(await screen.findByText("Try browser feedback")).toBeTruthy();
+
+ fireEvent.click(
+ screen.getByRole("button", { name: "Already downloaded?" })
+ );
+ expect(await screen.findByText("Finish setup in Chrome")).toBeTruthy();
+
+ fireEvent.click(screen.getByRole("button", { name: "Safari on iPad" }));
+
+ expect(screen.getByText("Install on iPad via TestFlight")).toBeTruthy();
+ expect(screen.getByText("1. Install from TestFlight")).toBeTruthy();
+ expect(
+ screen.getByText(/Settings → Apps → Safari → Extensions/)
+ ).toBeTruthy();
+ expect(screen.queryByText("Finish setup in Chrome")).toBe(null);
+ expect(screen.queryByText("chrome://extensions")).toBe(null);
+ // The Dispatch URL block is shared across both platform guides.
+ expect(
+ screen.getByRole("button", { name: "Copy Dispatch URL" })
+ ).toBeTruthy();
+
+ fireEvent.click(screen.getByRole("button", { name: "Chrome" }));
+ expect(screen.getByText("Finish setup in Chrome")).toBeTruthy();
+ });
+
it("lists multiple paired browsers and revokes only the selected one", async () => {
const fetchMock = vi
.mocked(globalThis.fetch)
@@ -230,7 +257,7 @@ describe("BrowserExtensionSettings", () => {
renderSettings("?browserExtensionPairing=pairing-123&code=ABCD-1234");
expect(
- screen.getByText("Chrome is requesting permission to connect")
+ screen.getByText("A browser is requesting permission to connect")
).toBeTruthy();
expect(
screen.getByText("Confirm this code matches the extension")
diff --git a/apps/web/src/components/app/browser-extension-settings.tsx b/apps/web/src/components/app/browser-extension-settings.tsx
index ad73323a..eaf7720b 100644
--- a/apps/web/src/components/app/browser-extension-settings.tsx
+++ b/apps/web/src/components/app/browser-extension-settings.tsx
@@ -5,6 +5,7 @@ import {
ChevronDown,
ChevronUp,
Chrome,
+ Compass,
Copy,
Download,
FolderOpen,
@@ -59,6 +60,9 @@ export function BrowserExtensionSettings(): JSX.Element {
const [approvalState, setApprovalState] = useState("idle");
const [error, setError] = useState("");
const [showInstallGuide, setShowInstallGuide] = useState(false);
+ const [installPlatform, setInstallPlatform] = useState<"chrome" | "safari">(
+ "chrome"
+ );
const [showAllConnections, setShowAllConnections] = useState(false);
const [copiedUrl, copyText] = useCopyText();
const connectionsBeforeApprovalRef = useRef>(new Set());
@@ -332,7 +336,7 @@ export function BrowserExtensionSettings(): JSX.Element {
- Chrome is requesting permission to connect
+ A browser is requesting permission to connect
Approve only if you started this request. The extension
@@ -452,66 +456,168 @@ export function BrowserExtensionSettings(): JSX.Element {
className="space-y-4 rounded-lg border border-border bg-background/40 p-4"
data-testid="extension-install-guide"
>
-
-
-
- Finish setup in Chrome
-
-
- The extension is a developer preview, so Chrome loads it
- from an unzipped folder for now.
-
-
- {hasConnections && (
-
-
-
- Download ZIP
-
-
- )}
-
-
-
-
-
- 1. Unzip the download
-
-
- Keep the extracted folder somewhere Chrome can continue
- to access it.
-
-
-
-
-
2. Load the folder
-
- Open{" "}
- chrome://extensions,
- enable Developer mode, then choose Load unpacked.
-
-
-
-
+ setInstallPlatform("chrome")}
+ >
+
+ Chrome
+
+ setInstallPlatform("safari")}
+ >
+
- 3. Connect Dispatch
-
- Open the extension, enter this Dispatch URL, and verify
- the pairing code here.
-
-
+ Safari on iPad
+
+ {installPlatform === "chrome" ? (
+ <>
+
+
+
+ Finish setup in Chrome
+
+
+ The extension is a developer preview, so Chrome
+ loads it from an unzipped folder for now.
+
+
+ {hasConnections && (
+
+
+
+ Download ZIP
+
+
+ )}
+
+
+
+
+
+ 1. Unzip the download
+
+
+ Keep the extracted folder somewhere Chrome can
+ continue to access it.
+
+
+
+
+
+ 2. Load the folder
+
+
+ Open{" "}
+
+ chrome://extensions
+
+ , enable Developer mode, then choose Load unpacked.
+
+
+
+
+
+ 3. Connect Dispatch
+
+
+ Open the extension, enter this Dispatch URL, and
+ verify the pairing code here.
+
+
+
+ >
+ ) : (
+ <>
+
+
+ Install on iPad via TestFlight
+
+
+ Safari extensions ship inside a small companion app.
+ Ask your Dispatch admin for the TestFlight invite, or
+ build it from{" "}
+
+ apps/browser-extension
+ {" "}
+ in the Dispatch repo.
+
+
+
+
+
+
+ 1. Install from TestFlight
+
+
+ Install the Dispatch Feedback app from your
+ TestFlight invite on the iPad.
+
+
+
+
+
+ 2. Enable the extension
+
+
+ Settings → Apps → Safari → Extensions → enable
+ Dispatch Browser Feedback, then allow it on the
+ sites you test.
+
+
+
+
+
+ 3. Connect Dispatch
+
+
+ Open the extension from Safari's toolbar, enter
+ this Dispatch URL, and verify the pairing code here.
+
+
+
+ >
+ )}
{dispatchUrl}
diff --git a/e2e/browser-extension-overlay.spec.ts b/e2e/browser-extension-overlay.spec.ts
new file mode 100644
index 00000000..10f2eec6
--- /dev/null
+++ b/e2e/browser-extension-overlay.spec.ts
@@ -0,0 +1,227 @@
+import { execSync } from "node:child_process";
+import { existsSync, readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { test, expect, type Page } from "@playwright/test";
+
+// The Safari feedback overlay is a plain content script, so it can be
+// exercised headlessly: a fixture page, a stubbed extension messaging bridge
+// on window.browser, and the built IIFE injected the way the background's
+// scripting.executeScript would.
+
+const overlayBundlePath = resolve(
+ import.meta.dirname,
+ "../apps/browser-extension/dist/safari/unpacked/feedback-overlay.js"
+);
+
+const fixtureHtml = `
+
+ Overlay fixture
+
+
+
+ Fixture page
+
+
+ Click me
+
+
+
+
+
+`;
+
+test.use({ hasTouch: true });
+
+test.beforeAll(() => {
+ if (!existsSync(overlayBundlePath)) {
+ execSync("pnpm --filter @dispatch/browser-extension build:safari", {
+ cwd: resolve(import.meta.dirname, ".."),
+ stdio: "inherit",
+ });
+ }
+});
+
+async function openFixture(page: Page): Promise {
+ await page.route("**/overlay-fixture", (route) =>
+ route.fulfill({ contentType: "text/html", body: fixtureHtml })
+ );
+ await page.addInitScript(() => {
+ const sent: unknown[] = [];
+ const win = window as typeof window & {
+ __sentMessages: unknown[];
+ __failNextSubmission: boolean;
+ browser: unknown;
+ };
+ win.__sentMessages = sent;
+ win.__failNextSubmission = false;
+ win.browser = {
+ runtime: {
+ sendMessage: (message: { type: string }) => {
+ sent.push(message);
+ switch (message.type) {
+ case "overlay:init":
+ return Promise.resolve({
+ ok: true,
+ data: {
+ connected: true,
+ baseUrl: "http://dispatch.test",
+ agents: [
+ { id: "agent-1", name: "fix-navbar", status: "running" },
+ {
+ id: "agent-2",
+ name: "refactor-auth",
+ status: "running",
+ repoName: "dispatch",
+ },
+ ],
+ selectedAgentId: "agent-2",
+ },
+ });
+ case "submission:create":
+ if (win.__failNextSubmission) {
+ win.__failNextSubmission = false;
+ return Promise.resolve({
+ ok: false,
+ error: "Dispatch is unreachable.",
+ });
+ }
+ return Promise.resolve({
+ ok: true,
+ data: { status: "delivered" },
+ });
+ default:
+ return Promise.resolve({ ok: true, data: {} });
+ }
+ },
+ },
+ };
+ });
+ await page.goto("/overlay-fixture");
+}
+
+async function injectOverlay(page: Page): Promise {
+ await page.addScriptTag({
+ content: readFileSync(overlayBundlePath, "utf8"),
+ });
+}
+
+function shadow(page: Page) {
+ return page.locator("[data-dispatch-feedback-host]");
+}
+
+test.describe("Safari feedback overlay", () => {
+ test("tap, refine, comment, and send with a stable retry id", async ({
+ page,
+ }) => {
+ await openFixture(page);
+ await injectOverlay(page);
+
+ const host = shadow(page);
+ await expect(host).toHaveCount(1);
+ await expect(page.getByText("Tap an element to select it")).toBeVisible();
+
+ // Aiming blocks page interaction: a tap selects instead of clicking.
+ await page.locator("#target-button").tap();
+ await expect(page.getByRole("button", { name: "Use ✓" })).toBeVisible();
+ expect(
+ await page.evaluate(() => (window as { __clicks?: number }).__clicks)
+ ).toBeUndefined();
+
+ const selector = host.locator(".toolbar-selector");
+ await expect(selector).toContainText("#target-button");
+
+ // Refine: up to the card, back down to the button.
+ await page.getByRole("button", { name: "‹ Parent" }).click();
+ await expect(selector).toContainText("#card");
+ await page.getByRole("button", { name: "Child ›" }).click();
+ await expect(selector).toContainText("#target-button");
+ await page.getByRole("button", { name: "‹ Parent" }).click();
+
+ // Confirm: the card appears with agents from overlay:init.
+ await page.getByRole("button", { name: "Use ✓" }).click();
+ const agentSelect = host.locator("select");
+ await expect(agentSelect).toBeVisible();
+ await expect(agentSelect).toHaveValue("agent-2");
+
+ // The aiming block is released: page clicks work again.
+ await page.locator("#target-button").click();
+ expect(
+ await page.evaluate(() => (window as { __clicks?: number }).__clicks)
+ ).toBe(1);
+
+ await host.locator("textarea").fill("Make this button purple");
+ await page.evaluate(() => {
+ (window as { __failNextSubmission?: boolean }).__failNextSubmission =
+ true;
+ });
+ await page.getByRole("button", { name: "Send" }).click();
+ await expect(host.locator(".card-error")).toContainText(
+ "Dispatch is unreachable."
+ );
+ await page.getByRole("button", { name: "Send" }).click();
+ await expect(host).toHaveCount(0);
+
+ const messages = await page.evaluate(
+ () => (window as { __sentMessages?: unknown[] }).__sentMessages ?? []
+ );
+ const submissions = messages.filter(
+ (
+ message
+ ): message is {
+ clientSubmissionId: string;
+ selection: { element: { selector: string } };
+ } => (message as { type?: string }).type === "submission:create"
+ );
+ expect(submissions).toHaveLength(2);
+ expect(submissions[0].clientSubmissionId).toBe(
+ submissions[1].clientSubmissionId
+ );
+ expect(submissions[0].selection.element.selector).toContain("#card");
+ expect(
+ messages.some(
+ (message) =>
+ (message as { type?: string; reason?: string }).type ===
+ "overlay:closed" &&
+ (message as { reason?: string }).reason === "submitted"
+ )
+ ).toBe(true);
+ expect(
+ await page.evaluate(() => typeof window.__dispatchElementPickerCleanup)
+ ).toBe("undefined");
+ });
+
+ test("cancel tears the overlay down without submitting", async ({ page }) => {
+ await openFixture(page);
+ await injectOverlay(page);
+
+ await page.locator("#headline").tap();
+ await page.getByRole("button", { name: "Cancel selection" }).click();
+ await expect(shadow(page)).toHaveCount(0);
+
+ const messages = await page.evaluate(
+ () => (window as { __sentMessages?: unknown[] }).__sentMessages ?? []
+ );
+ expect(
+ messages.some(
+ (message) =>
+ (message as { type?: string; reason?: string }).type ===
+ "overlay:closed" &&
+ (message as { reason?: string }).reason === "cancelled"
+ )
+ ).toBe(true);
+ expect(
+ messages.some(
+ (message) => (message as { type?: string }).type === "submission:create"
+ )
+ ).toBe(false);
+ });
+
+ test("re-injection replaces a previous overlay instance", async ({
+ page,
+ }) => {
+ await openFixture(page);
+ await injectOverlay(page);
+ await injectOverlay(page);
+ await expect(shadow(page)).toHaveCount(1);
+ });
+});
diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts
index bb24e325..cad5fee5 100644
--- a/e2e/settings.spec.ts
+++ b/e2e/settings.spec.ts
@@ -118,7 +118,7 @@ test.describe("Settings pane", () => {
});
await expect(
- page.getByText("Chrome is requesting permission to connect")
+ page.getByText("A browser is requesting permission to connect")
).toBeVisible();
await page.getByRole("button", { name: "Approve connection" }).click();
await expect(page.getByText("Connection approved")).toBeVisible();