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

- Chrome is requesting permission to connect + A browser is requesting permission to connect

Approve only if you started this request. The extension @@ -452,66 +456,168 @@ export function BrowserExtensionSettings(): JSX.Element { className="space-y-4 rounded-lg border border-border bg-background/40 p-4" data-testid="extension-install-guide" > -

-
-

- Finish setup in Chrome -

-

- The extension is a developer preview, so Chrome loads it - from an unzipped folder for now. -

-
- {hasConnections && ( - - )} -
-
-
-
-
-
-
- + +
+ 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();