From cfa9b9ea4c2ad9fdaa071e47c77d357052fc1344 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 16 Jul 2026 19:50:38 -0600 Subject: [PATCH 1/3] Add mobile Safari (iPadOS) version of the browser feedback extension Ports the Dispatch browser feedback extension to Safari Web Extensions with a mobile-first flow: the popup handles connect + arming, and element picking (tap + parent/child refine), the comment card, and Send all happen in a transient in-page overlay that removes itself when done. - Extract shared worker core from the Chrome service worker (behavior unchanged); share element-context, dispatch-url, feedback-form, types - Safari background owns pairing polling (popup dies when the verification tab opens) with a persisted pending record that resumes across worker death - Tap + refine overlay: capture-phase aiming that blocks clicks but keeps scrolling, shadow-DOM comment card with visualViewport keyboard handling, idempotent submission retry via stable clientSubmissionId - Checked-in Xcode project (safari-web-extension-converter shell) that references dist/safari/unpacked; TestFlight steps documented in README - Web settings: Chrome / Safari on iPad install guide toggle - Playwright harness drives the built overlay headlessly with a stubbed extension bridge; unit tests for refine/popup/pairing/viewport/manifests - Fix stale Chrome manifest version (0.28.4 -> 0.29.0) Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + apps/browser-extension/README.md | 49 ++ apps/browser-extension/manifest.safari.json | 25 + apps/browser-extension/package.json | 1 + apps/browser-extension/popup.html | 12 + apps/browser-extension/public/manifest.json | 2 +- .../Dispatch Feedback Extension/Info.plist | 13 + .../SafariWebExtensionHandler.swift | 42 ++ .../project.pbxproj | 626 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../Dispatch Feedback/AppDelegate.swift | 24 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 38 ++ .../universal-icon-1024@1x.png | Bin 0 -> 124435 bytes .../Assets.xcassets/Contents.json | 6 + .../LargeIcon.imageset/Contents.json | 21 + .../LargeIcon.imageset/icon-128.png | Bin 0 -> 7123 bytes .../Base.lproj/LaunchScreen.storyboard | 36 + .../Base.lproj/Main.storyboard | 38 ++ .../Dispatch Feedback/Info.plist | 27 + .../Resources/Base.lproj/Main.html | 23 + .../Dispatch Feedback/Resources/Icon.png | Bin 0 -> 7123 bytes .../Dispatch Feedback/Resources/Style.css | 29 + .../Dispatch Feedback/SceneDelegate.swift | 18 + .../Dispatch Feedback/ViewController.swift | 34 + .../browser-extension/src/lib/agent-memory.ts | 32 + .../src/lib/device-name.test.ts | 38 +- apps/browser-extension/src/lib/device-name.ts | 21 + .../src/lib/extension-api.ts | 13 + .../src/lib/manifest.test.ts | 45 ++ apps/browser-extension/src/lib/worker-core.ts | 239 +++++++ .../src/safari/background.ts | 196 ++++++ .../src/safari/overlay-session.ts | 155 +++++ .../src/safari/overlay/aim-layer.ts | 117 ++++ .../src/safari/overlay/card.ts | 275 ++++++++ .../src/safari/overlay/index.ts | 443 +++++++++++++ .../src/safari/overlay/refine.test.ts | 91 +++ .../src/safari/overlay/refine.ts | 53 ++ .../src/safari/overlay/viewport.test.ts | 44 ++ .../src/safari/overlay/viewport.ts | 80 +++ .../src/safari/pairing-session.test.ts | 186 ++++++ .../src/safari/pairing-session.ts | 161 +++++ .../src/safari/popup-state.test.ts | 160 +++++ .../src/safari/popup-state.ts | 230 +++++++ apps/browser-extension/src/safari/popup.css | 164 +++++ apps/browser-extension/src/safari/popup.ts | 382 +++++++++++ apps/browser-extension/src/service-worker.ts | 238 +------ apps/browser-extension/src/types.test.ts | 27 +- apps/browser-extension/src/types.ts | 58 ++ apps/browser-extension/tsconfig.json | 9 +- .../vite.safari.background.config.ts | 20 + apps/browser-extension/vite.safari.config.ts | 41 ++ .../vite.safari.overlay.config.ts | 19 + .../app/browser-extension-settings.test.tsx | 29 +- .../app/browser-extension-settings.tsx | 222 +++++-- e2e/browser-extension-overlay.spec.ts | 227 +++++++ e2e/settings.spec.ts | 2 +- 57 files changed, 4804 insertions(+), 298 deletions(-) create mode 100644 apps/browser-extension/manifest.safari.json create mode 100644 apps/browser-extension/popup.html create mode 100644 apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback Extension/Info.plist create mode 100644 apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback Extension/SafariWebExtensionHandler.swift create mode 100644 apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback.xcodeproj/project.pbxproj create mode 100644 apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/AppDelegate.swift create mode 100644 apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/AppIcon.appiconset/universal-icon-1024@1x.png create mode 100644 apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/Contents.json create mode 100644 apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/LargeIcon.imageset/Contents.json create mode 100644 apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Assets.xcassets/LargeIcon.imageset/icon-128.png create mode 100644 apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Base.lproj/LaunchScreen.storyboard create mode 100644 apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Base.lproj/Main.storyboard create mode 100644 apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Info.plist create mode 100644 apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Resources/Base.lproj/Main.html create mode 100644 apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Resources/Icon.png create mode 100644 apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/Resources/Style.css create mode 100644 apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/SceneDelegate.swift create mode 100644 apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback/ViewController.swift create mode 100644 apps/browser-extension/src/lib/agent-memory.ts create mode 100644 apps/browser-extension/src/lib/extension-api.ts create mode 100644 apps/browser-extension/src/lib/worker-core.ts create mode 100644 apps/browser-extension/src/safari/background.ts create mode 100644 apps/browser-extension/src/safari/overlay-session.ts create mode 100644 apps/browser-extension/src/safari/overlay/aim-layer.ts create mode 100644 apps/browser-extension/src/safari/overlay/card.ts create mode 100644 apps/browser-extension/src/safari/overlay/index.ts create mode 100644 apps/browser-extension/src/safari/overlay/refine.test.ts create mode 100644 apps/browser-extension/src/safari/overlay/refine.ts create mode 100644 apps/browser-extension/src/safari/overlay/viewport.test.ts create mode 100644 apps/browser-extension/src/safari/overlay/viewport.ts create mode 100644 apps/browser-extension/src/safari/pairing-session.test.ts create mode 100644 apps/browser-extension/src/safari/pairing-session.ts create mode 100644 apps/browser-extension/src/safari/popup-state.test.ts create mode 100644 apps/browser-extension/src/safari/popup-state.ts create mode 100644 apps/browser-extension/src/safari/popup.css create mode 100644 apps/browser-extension/src/safari/popup.ts create mode 100644 apps/browser-extension/vite.safari.background.config.ts create mode 100644 apps/browser-extension/vite.safari.config.ts create mode 100644 apps/browser-extension/vite.safari.overlay.config.ts create mode 100644 e2e/browser-extension-overlay.spec.ts 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..25eab6fc 100644 --- a/apps/browser-extension/README.md +++ b/apps/browser-extension/README.md @@ -60,6 +60,55 @@ 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 + +1. `pnpm --filter @dispatch/browser-extension build:safari` +2. Open the Xcode project and set your Team on both targets (Signing & + Capabilities). Bundle IDs default to `com.dispatch.feedback` / + `com.dispatch.feedback.extension`; adjust to your team's prefix if needed. +3. Verify `MARKETING_VERSION` matches `package.json` (a vitest check enforces + this) and bump the build number for each upload. +4. Select "Any iOS Device (arm64)" → Product → Archive → Distribute App → + TestFlight & App Store Connect. +5. In App Store Connect, add yourself as an internal tester and install the + build via TestFlight on the iPad. +6. 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..bc0681f1 --- /dev/null +++ b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback.xcodeproj/project.pbxproj @@ -0,0 +1,626 @@ +// !$*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; + 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 = com.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; + 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 = com.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; + 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 = com.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; + 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 = com.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 0000000000000000000000000000000000000000..1ba3d1a56a5dcac6934bbffb0b277fca42ff2a08 GIT binary patch literal 124435 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ᑮytL?qtqo*Kt1m;A%mSA>+UsBb4E&Ue8YK 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�X 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + Dispatch Feedback Icon +

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 0000000000000000000000000000000000000000..43ef9c750dfff60415e88ba68a089be01a9870ef GIT binary patch literal 7123 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 + 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" > -

-
-
-
-
-
-
- + +
+ 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 && ( + + )} +
+
+
+
+
+
+
+
+
+ + ) : ( + <> +
+

+ 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. +

+
+
+
+
+
+
+
+
+
+ + )}
{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

+
+ +
+
+
+ +`; + +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(); From bad2a547b062ad8f5bae33f4c8582657a9a8002b Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 16 Jul 2026 21:48:32 -0600 Subject: [PATCH 2/3] Preconfigure Safari app signing with the release team Sets DEVELOPMENT_TEAM (ML8BQ6D727, same team as the Mac release signing) on all Safari app/extension configs so automatic signing works out of the box, and updates the TestFlight README steps accordingly. Device archive verified headlessly; App Store export still needs an ASC API key or the Xcode GUI to mint distribution profiles. Co-Authored-By: Claude Fable 5 --- apps/browser-extension/README.md | 29 +++++++++++++------ .../project.pbxproj | 4 +++ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/apps/browser-extension/README.md b/apps/browser-extension/README.md index 25eab6fc..0731f98a 100644 --- a/apps/browser-extension/README.md +++ b/apps/browser-extension/README.md @@ -89,17 +89,28 @@ 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 +`com.dispatch.feedback` / `com.dispatch.feedback.extension`. + +One-time setup: create the app record in App Store Connect (My Apps → New +App → iOS, bundle ID `com.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. Open the Xcode project and set your Team on both targets (Signing & - Capabilities). Bundle IDs default to `com.dispatch.feedback` / - `com.dispatch.feedback.extension`; adjust to your team's prefix if needed. -3. Verify `MARKETING_VERSION` matches `package.json` (a vitest check enforces - this) and bump the build number for each upload. -4. Select "Any iOS Device (arm64)" → Product → Archive → Distribute App → - TestFlight & App Store Connect. -5. In App Store Connect, add yourself as an internal tester and install the +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. -6. On the iPad: Settings → Apps → Safari → Extensions → enable Dispatch +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. 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 index bc0681f1..56a168a8 100644 --- a/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback.xcodeproj/project.pbxproj +++ b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback.xcodeproj/project.pbxproj @@ -452,6 +452,7 @@ 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"; @@ -484,6 +485,7 @@ 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"; @@ -518,6 +520,7 @@ 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"; @@ -557,6 +560,7 @@ 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"; From 4c900ffd7a6b6bf5c6fbbdb3566aea9af338705b Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 16 Jul 2026 21:56:44 -0600 Subject: [PATCH 3/3] Rename Safari app bundle IDs to the dev.bradharris prefix Matches the Mac binary signing convention (dev.bradharris.dispatch): app is dev.bradharris.dispatch.feedback, extension appends .extension. Device archive re-verified with automatic signing. Co-Authored-By: Claude Fable 5 --- apps/browser-extension/README.md | 4 ++-- .../Dispatch Feedback.xcodeproj/project.pbxproj | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/browser-extension/README.md b/apps/browser-extension/README.md index 0731f98a..df8b06a3 100644 --- a/apps/browser-extension/README.md +++ b/apps/browser-extension/README.md @@ -91,10 +91,10 @@ feedback. The project is preconfigured with automatic signing for team `ML8BQ6D727` (the same team the Mac release binaries sign with) and bundle IDs -`com.dispatch.feedback` / `com.dispatch.feedback.extension`. +`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 `com.dispatch.feedback`). If this Mac has no Apple +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. 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 index 56a168a8..3ebb2013 100644 --- a/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback.xcodeproj/project.pbxproj +++ b/apps/browser-extension/safari/Dispatch Feedback/Dispatch Feedback.xcodeproj/project.pbxproj @@ -469,7 +469,7 @@ "-framework", SafariServices, ); - PRODUCT_BUNDLE_IDENTIFIER = com.dispatch.feedback.extension; + PRODUCT_BUNDLE_IDENTIFIER = dev.bradharris.dispatch.feedback.extension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -502,7 +502,7 @@ "-framework", SafariServices, ); - PRODUCT_BUNDLE_IDENTIFIER = com.dispatch.feedback.extension; + PRODUCT_BUNDLE_IDENTIFIER = dev.bradharris.dispatch.feedback.extension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -542,7 +542,7 @@ "-framework", WebKit, ); - PRODUCT_BUNDLE_IDENTIFIER = com.dispatch.feedback; + PRODUCT_BUNDLE_IDENTIFIER = dev.bradharris.dispatch.feedback; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -582,7 +582,7 @@ "-framework", WebKit, ); - PRODUCT_BUNDLE_IDENTIFIER = com.dispatch.feedback; + PRODUCT_BUNDLE_IDENTIFIER = dev.bradharris.dispatch.feedback; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;