From 2e89233e612264c985800dd7c950478e6f14e47e Mon Sep 17 00:00:00 2001 From: Michael Stevens Date: Thu, 4 Jun 2026 17:22:16 -0700 Subject: [PATCH 1/3] Fix CORS/PNA, write @AppStorage defaults, switch to CGEvent, make port configurable Addresses #5. WebServer: handle OPTIONS preflight with Origin echo, Allow-Credentials, and Allow-Private-Network so the bundled browser extension can actually reach the server from modern Chrome (PNA + chrome-extension origin previously blocked every POST). Port is now read from UserDefaults, defaults to 3030 (3000 collides with most dev servers), and supports restart() for live changes. HapticMasterApp: register UserDefaults defaults on launch so non-SwiftUI reads (WebServer.processJson, NotificationWatcher) see the same defaults the @AppStorage bindings advertise. Fresh installs previously had web haptics and notification haptics silently disabled. HapticEngine: replace NSAppleScript path with CGEvent.post. AppleScript required a separate Automation permission for System Events that is often suppressed on ad-hoc signed builds (macOS 14+) due to cdhash validation failures; CGEvent uses only the already-granted Accessibility permission and is functionally equivalent for Logi Options+'s Smart Action listener. ContentView: port selection TextField in the Web Browser card. Persisted via @AppStorage("webServerPort"), validates 1024-65535, triggers WebServer.restart() on commit. build_with_icon.sh: add set -e and remove a stale "$APP_NAME.app" before building so the final mv doesn't silently fail and deploy stale artifacts. extension / extension_firefox: bump hardcoded port from 3000 to 3030 to match the new default. Configurable extension port is a possible follow-up. Co-Authored-By: Claude Opus 4.7 --- HapticMaster/ContentView.swift | 35 +++++++++- HapticMaster/HapticEngine.swift | 28 ++++---- HapticMaster/HapticMasterApp.swift | 12 +++- HapticMaster/WebServer.swift | 100 ++++++++++++++++++----------- HapticMaster/build_with_icon.sh | 5 +- extension/background.js | 2 +- extension/manifest.json | 2 +- extension/popup.js | 2 +- extension_firefox/background.js | 2 +- extension_firefox/manifest.json | 2 +- extension_firefox/popup.js | 2 +- 11 files changed, 129 insertions(+), 63 deletions(-) diff --git a/HapticMaster/ContentView.swift b/HapticMaster/ContentView.swift index 26102b3..6771f60 100644 --- a/HapticMaster/ContentView.swift +++ b/HapticMaster/ContentView.swift @@ -8,6 +8,8 @@ struct ContentView: View { @AppStorage("systemNotificationsEnabled") private var systemNotificationsEnabled: Bool = true @AppStorage("notificationPattern") private var notificationPattern: String = "pulse" @AppStorage("allowWebHaptics") private var allowWebHaptics: Bool = true + @AppStorage("webServerPort") private var webServerPort: Int = 3030 + @State private var portFieldText: String = "" // --- Data --- let patterns: [String: String] = [ @@ -148,7 +150,24 @@ struct ContentView: View { if allowWebHaptics { Divider().opacity(0.5) - + + HStack { + Text("Port") + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + TextField("3030", text: $portFieldText) + .textFieldStyle(.roundedBorder) + .frame(width: 80) + .onSubmit { applyPortChange() } + } + + Text("Browser extension must use the same port.") + .font(.caption2) + .foregroundStyle(.secondary) + + Divider().opacity(0.5) + Text("Customize patterns in your browser extension.") .font(.caption) .foregroundStyle(.secondary) @@ -205,7 +224,19 @@ struct ContentView: View { .padding(.bottom, 15) } } - .frame(width: 340, height: 480) + .frame(width: 340, height: 520) + .onAppear { portFieldText = String(webServerPort) } + } + + private func applyPortChange() { + guard let p = Int(portFieldText.trimmingCharacters(in: .whitespaces)), + p >= 1024, p <= 65535 else { + portFieldText = String(webServerPort) + return + } + guard p != webServerPort else { return } + webServerPort = p + WebServer.shared.restart() } } diff --git a/HapticMaster/HapticEngine.swift b/HapticMaster/HapticEngine.swift index 928aa75..623330b 100644 --- a/HapticMaster/HapticEngine.swift +++ b/HapticMaster/HapticEngine.swift @@ -1,15 +1,12 @@ import Foundation +import CoreGraphics class HapticEngine { static let shared = HapticEngine() - - // AppleScript command to trigger the shortcut - // New: Cmd+Ctrl+Opt+Shift+U (to avoid Finder 'Utilities' conflict) - private let triggerScript = """ - tell application "System Events" - key code 32 using {command down, shift down, control down, option down} - end tell - """ + + // Cmd+Ctrl+Opt+Shift+U via CGEvent (avoids System Events / Automation perms) + private let keyCodeU: CGKeyCode = 32 + private let modifiers: CGEventFlags = [.maskCommand, .maskShift, .maskControl, .maskAlternate] func playPattern(_ pattern: String) { print("Playing pattern: \(pattern)") @@ -63,13 +60,14 @@ class HapticEngine { } private func trigger() { - // Execute AppleScript - var error: NSDictionary? - if let scriptObject = NSAppleScript(source: triggerScript) { - scriptObject.executeAndReturnError(&error) - if let error = error { - print("AppleScript Error: \(error)") - } + let src = CGEventSource(stateID: .hidSystemState) + guard let down = CGEvent(keyboardEventSource: src, virtualKey: keyCodeU, keyDown: true), + let up = CGEvent(keyboardEventSource: src, virtualKey: keyCodeU, keyDown: false) else { + return } + down.flags = modifiers + up.flags = modifiers + down.post(tap: .cghidEventTap) + up.post(tap: .cghidEventTap) } } diff --git a/HapticMaster/HapticMasterApp.swift b/HapticMaster/HapticMasterApp.swift index 6914b66..3bd0999 100644 --- a/HapticMaster/HapticMasterApp.swift +++ b/HapticMaster/HapticMasterApp.swift @@ -15,10 +15,20 @@ struct HapticMasterApp: App { class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { + // @AppStorage defaults are only applied to the SwiftUI binding; non-SwiftUI + // reads (WebServer.processJson, NotificationWatcher) hit raw UserDefaults + // and would otherwise see false / nil on a fresh install. + UserDefaults.standard.register(defaults: [ + "allowWebHaptics": true, + "systemNotificationsEnabled": true, + "notificationPattern": "pulse", + "webServerPort": 3030 + ]) + WebServer.shared.start() NotificationWatcher.shared.start() } - + func applicationWillTerminate(_ notification: Notification) { WebServer.shared.stop() } diff --git a/HapticMaster/WebServer.swift b/HapticMaster/WebServer.swift index f37f88d..6acb600 100644 --- a/HapticMaster/WebServer.swift +++ b/HapticMaster/WebServer.swift @@ -4,33 +4,40 @@ import Combine class WebServer: ObservableObject { static let shared = WebServer() + static let defaultPort: UInt16 = 3030 private var listener: NWListener? - private let port: NWEndpoint.Port = 3000 - + @Published var currentPort: UInt16 = WebServer.defaultPort + // UI State @Published var status: String = "Initializing..." @Published var isConnected: Bool = false @Published var lastSignalTime: Date? = nil - - // Helper for UI formatting + var statusColor: String { isConnected ? "green" : "red" } - + + private func resolvePort() -> UInt16 { + let stored = UserDefaults.standard.integer(forKey: "webServerPort") + if stored >= 1024 && stored <= 65535 { return UInt16(stored) } + return WebServer.defaultPort + } + func start() { + let port = resolvePort() + DispatchQueue.main.async { self.currentPort = port } do { let parameters = NWParameters.tcp - listener = try NWListener(using: parameters, on: port) - + let nwPort = NWEndpoint.Port(rawValue: port)! + listener = try NWListener(using: parameters, on: nwPort) + listener?.stateUpdateHandler = { state in DispatchQueue.main.async { switch state { case .ready: - self.status = "Listening on Port \(self.port)" + self.status = "Listening on Port \(port)" self.isConnected = true - print("Server ready on port \(self.port)") case .failed(let error): self.status = "Failed: \(error.localizedDescription)" self.isConnected = false - print("Server failed: \(error)") case .waiting(let error): self.status = "Waiting: \(error.localizedDescription)" self.isConnected = false @@ -39,69 +46,88 @@ class WebServer: ObservableObject { } } } - + listener?.newConnectionHandler = { connection in connection.start(queue: .global()) self.handleConnection(connection) } - + listener?.start(queue: .global()) } catch { - print("Failed to create listener: \(error)") DispatchQueue.main.async { - self.status = "Error starting server" + self.status = "Port \(port) in use" self.isConnected = false } } } - + func stop() { listener?.cancel() + listener = nil DispatchQueue.main.async { self.status = "Stopped" self.isConnected = false } } + + func restart() { + stop() + DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { + self.start() + } + } private func handleConnection(_ connection: NWConnection) { connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, isComplete, error in if let data = data, let message = String(data: data, encoding: .utf8) { - // Update Last Signal on Main Thread DispatchQueue.main.async { self.lastSignalTime = Date() } - - if let bodyRange = message.range(of: "\r\n\r\n") { - let bodyString = String(message[bodyRange.upperBound...]) - self.processJson(bodyString) + + let isOptions = message.hasPrefix("OPTIONS ") + var origin = "*" + for line in message.split(separator: "\r\n") { + let s = String(line) + if s.lowercased().hasPrefix("origin:") { + origin = String(s.dropFirst("origin:".count)).trimmingCharacters(in: .whitespaces) + break + } + } + let corsHeaders = + "Access-Control-Allow-Origin: \(origin)\r\n" + + "Access-Control-Allow-Credentials: true\r\n" + + "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n" + + "Access-Control-Allow-Headers: Content-Type\r\n" + + "Access-Control-Allow-Private-Network: true\r\n" + + "Access-Control-Max-Age: 86400\r\n" + + "Vary: Origin\r\n" + + let response: String + if isOptions { + response = "HTTP/1.1 204 No Content\r\n" + corsHeaders + "Content-Length: 0\r\n\r\n" + } else { + if let bodyRange = message.range(of: "\r\n\r\n") { + let bodyString = String(message[bodyRange.upperBound...]) + self.processJson(bodyString) + } + response = "HTTP/1.1 200 OK\r\n" + corsHeaders + "Content-Length: 2\r\n\r\nok" } - - let response = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok" + connection.send(content: response.data(using: .utf8), completion: .contentProcessed({ _ in connection.cancel() })) } } } - + private func processJson(_ jsonString: String) { - if UserDefaults.standard.bool(forKey: "allowWebHaptics") == false { - print("Web ignored (User Disabled)") - return - } - + if UserDefaults.standard.bool(forKey: "allowWebHaptics") == false { return } guard let data = jsonString.data(using: .utf8) else { return } - do { if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { - if let pattern = json["pattern"] as? String { - HapticEngine.shared.playPattern(pattern) - } else { - HapticEngine.shared.playPattern("single") - } + let pattern = (json["pattern"] as? String) ?? "single" + HapticEngine.shared.playPattern(pattern) } - } catch { - print("JSON Error: \(error)") - } + } catch {} } } diff --git a/HapticMaster/build_with_icon.sh b/HapticMaster/build_with_icon.sh index 3901f33..e629534 100755 --- a/HapticMaster/build_with_icon.sh +++ b/HapticMaster/build_with_icon.sh @@ -1,4 +1,5 @@ #!/bin/bash +set -e # Configuration APP_NAME="Haptic Master" @@ -8,8 +9,8 @@ OUTPUT_DIR="build" echo "🚀 Starting Build for $APP_NAME..." -# 1. Cleanup -rm -rf "$OUTPUT_DIR" +# 1. Cleanup (also remove any prior bundle at the destination so the final mv succeeds) +rm -rf "$OUTPUT_DIR" "$APP_NAME.app" mkdir -p "$OUTPUT_DIR" # 2. Create Icon diff --git a/extension/background.js b/extension/background.js index b60992c..05bd3ac 100644 --- a/extension/background.js +++ b/extension/background.js @@ -4,7 +4,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === "TRIGGER_HAPTIC") { // console.log("Forwarding haptic trigger", message); - fetch('http://localhost:3000/haptic', { + fetch('http://localhost:3030/haptic', { method: 'POST', headers: { 'Content-Type': 'application/json' diff --git a/extension/manifest.json b/extension/manifest.json index 9c851af..368cd1d 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -7,7 +7,7 @@ "storage" ], "host_permissions": [ - "http://localhost:3000/*" + "http://localhost:3030/*" ], "action": { "default_popup": "popup.html", diff --git a/extension/popup.js b/extension/popup.js index 746d29f..65535cd 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -7,7 +7,7 @@ const statusDot = document.querySelector('.dot'); // Check Connection to Native App const checkConnection = () => { - fetch('http://localhost:3000') + fetch('http://localhost:3030') .then(res => { if (res.ok) { connectionStatus.textContent = "Connected"; diff --git a/extension_firefox/background.js b/extension_firefox/background.js index b60992c..05bd3ac 100644 --- a/extension_firefox/background.js +++ b/extension_firefox/background.js @@ -4,7 +4,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === "TRIGGER_HAPTIC") { // console.log("Forwarding haptic trigger", message); - fetch('http://localhost:3000/haptic', { + fetch('http://localhost:3030/haptic', { method: 'POST', headers: { 'Content-Type': 'application/json' diff --git a/extension_firefox/manifest.json b/extension_firefox/manifest.json index 329121c..573820d 100644 --- a/extension_firefox/manifest.json +++ b/extension_firefox/manifest.json @@ -7,7 +7,7 @@ "storage" ], "host_permissions": [ - "http://localhost:3000/*" + "http://localhost:3030/*" ], "action": { "default_popup": "popup.html", diff --git a/extension_firefox/popup.js b/extension_firefox/popup.js index 746d29f..65535cd 100644 --- a/extension_firefox/popup.js +++ b/extension_firefox/popup.js @@ -7,7 +7,7 @@ const statusDot = document.querySelector('.dot'); // Check Connection to Native App const checkConnection = () => { - fetch('http://localhost:3000') + fetch('http://localhost:3030') .then(res => { if (res.ok) { connectionStatus.textContent = "Connected"; From b4d2a74d3e51b4721ced37a38904353e59c9044a Mon Sep 17 00:00:00 2001 From: Michael Stevens Date: Thu, 4 Jun 2026 17:32:27 -0700 Subject: [PATCH 2/3] Keep default port at 3000, override via UI Reverts the default-port change to 3000 to preserve existing user expectations (matches README and historical installs). Override behavior is unchanged: change "webServerPort" via the Web Browser card when 3000 is occupied. Co-Authored-By: Claude Opus 4.7 --- HapticMaster/ContentView.swift | 4 ++-- HapticMaster/HapticMasterApp.swift | 2 +- HapticMaster/WebServer.swift | 2 +- extension/background.js | 2 +- extension/manifest.json | 2 +- extension/popup.js | 2 +- extension_firefox/background.js | 2 +- extension_firefox/manifest.json | 2 +- extension_firefox/popup.js | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/HapticMaster/ContentView.swift b/HapticMaster/ContentView.swift index 6771f60..81bcc02 100644 --- a/HapticMaster/ContentView.swift +++ b/HapticMaster/ContentView.swift @@ -8,7 +8,7 @@ struct ContentView: View { @AppStorage("systemNotificationsEnabled") private var systemNotificationsEnabled: Bool = true @AppStorage("notificationPattern") private var notificationPattern: String = "pulse" @AppStorage("allowWebHaptics") private var allowWebHaptics: Bool = true - @AppStorage("webServerPort") private var webServerPort: Int = 3030 + @AppStorage("webServerPort") private var webServerPort: Int = 3000 @State private var portFieldText: String = "" // --- Data --- @@ -156,7 +156,7 @@ struct ContentView: View { .font(.subheadline) .foregroundStyle(.secondary) Spacer() - TextField("3030", text: $portFieldText) + TextField("3000", text: $portFieldText) .textFieldStyle(.roundedBorder) .frame(width: 80) .onSubmit { applyPortChange() } diff --git a/HapticMaster/HapticMasterApp.swift b/HapticMaster/HapticMasterApp.swift index 3bd0999..3f4424b 100644 --- a/HapticMaster/HapticMasterApp.swift +++ b/HapticMaster/HapticMasterApp.swift @@ -22,7 +22,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { "allowWebHaptics": true, "systemNotificationsEnabled": true, "notificationPattern": "pulse", - "webServerPort": 3030 + "webServerPort": 3000 ]) WebServer.shared.start() diff --git a/HapticMaster/WebServer.swift b/HapticMaster/WebServer.swift index 6acb600..6a9d208 100644 --- a/HapticMaster/WebServer.swift +++ b/HapticMaster/WebServer.swift @@ -4,7 +4,7 @@ import Combine class WebServer: ObservableObject { static let shared = WebServer() - static let defaultPort: UInt16 = 3030 + static let defaultPort: UInt16 = 3000 private var listener: NWListener? @Published var currentPort: UInt16 = WebServer.defaultPort diff --git a/extension/background.js b/extension/background.js index 05bd3ac..b60992c 100644 --- a/extension/background.js +++ b/extension/background.js @@ -4,7 +4,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === "TRIGGER_HAPTIC") { // console.log("Forwarding haptic trigger", message); - fetch('http://localhost:3030/haptic', { + fetch('http://localhost:3000/haptic', { method: 'POST', headers: { 'Content-Type': 'application/json' diff --git a/extension/manifest.json b/extension/manifest.json index 368cd1d..9c851af 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -7,7 +7,7 @@ "storage" ], "host_permissions": [ - "http://localhost:3030/*" + "http://localhost:3000/*" ], "action": { "default_popup": "popup.html", diff --git a/extension/popup.js b/extension/popup.js index 65535cd..746d29f 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -7,7 +7,7 @@ const statusDot = document.querySelector('.dot'); // Check Connection to Native App const checkConnection = () => { - fetch('http://localhost:3030') + fetch('http://localhost:3000') .then(res => { if (res.ok) { connectionStatus.textContent = "Connected"; diff --git a/extension_firefox/background.js b/extension_firefox/background.js index 05bd3ac..b60992c 100644 --- a/extension_firefox/background.js +++ b/extension_firefox/background.js @@ -4,7 +4,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === "TRIGGER_HAPTIC") { // console.log("Forwarding haptic trigger", message); - fetch('http://localhost:3030/haptic', { + fetch('http://localhost:3000/haptic', { method: 'POST', headers: { 'Content-Type': 'application/json' diff --git a/extension_firefox/manifest.json b/extension_firefox/manifest.json index 573820d..329121c 100644 --- a/extension_firefox/manifest.json +++ b/extension_firefox/manifest.json @@ -7,7 +7,7 @@ "storage" ], "host_permissions": [ - "http://localhost:3030/*" + "http://localhost:3000/*" ], "action": { "default_popup": "popup.html", diff --git a/extension_firefox/popup.js b/extension_firefox/popup.js index 65535cd..746d29f 100644 --- a/extension_firefox/popup.js +++ b/extension_firefox/popup.js @@ -7,7 +7,7 @@ const statusDot = document.querySelector('.dot'); // Check Connection to Native App const checkConnection = () => { - fetch('http://localhost:3030') + fetch('http://localhost:3000') .then(res => { if (res.ok) { connectionStatus.textContent = "Connected"; From b6edc7be528b32bbdf5eb219db3a84fac5674dbb Mon Sep 17 00:00:00 2001 From: Michael Stevens Date: Thu, 4 Jun 2026 19:07:39 -0700 Subject: [PATCH 3/3] Extension: make server port configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit popup gets a Server Port input that persists to chrome.storage.local (default 3000). popup.js uses it for the connection probe; background.js reads it from storage before each /haptic fetch. manifest host_permissions changed from "http://localhost:3000/*" to "http://localhost/*" — ports are not part of match patterns, so this matches any localhost port without needing wildcards. Co-Authored-By: Claude Opus 4.7 --- extension/background.js | 39 +++++++++++++++++---------------- extension/manifest.json | 2 +- extension/popup.html | 12 ++++++++++ extension/popup.js | 29 ++++++++++++++++++------ extension/styles.css | 18 +++++++++++++++ extension_firefox/background.js | 39 +++++++++++++++++---------------- extension_firefox/manifest.json | 2 +- extension_firefox/popup.html | 12 ++++++++++ extension_firefox/popup.js | 29 ++++++++++++++++++------ extension_firefox/styles.css | 18 +++++++++++++++ 10 files changed, 146 insertions(+), 54 deletions(-) diff --git a/extension/background.js b/extension/background.js index b60992c..7160d78 100644 --- a/extension/background.js +++ b/extension/background.js @@ -2,26 +2,27 @@ console.log("MX4 Background Worker Loaded"); chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === "TRIGGER_HAPTIC") { - // console.log("Forwarding haptic trigger", message); - - fetch('http://localhost:3000/haptic', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - channel: message.channel || 1, - pattern: message.pattern || "single" - }) - }) - .then(res => { - if (!res.ok) { - console.error("Bridge error:", res.status); - } + chrome.storage.local.get({ port: 3000 }, (items) => { + const port = (Number.isInteger(items.port) && items.port >= 1024 && items.port <= 65535) ? items.port : 3000; + fetch(`http://localhost:${port}/haptic`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + channel: message.channel || 1, + pattern: message.pattern || "single" + }) }) - .catch(err => { - console.error("Bridge connection failed:", err); - }); + .then(res => { + if (!res.ok) { + console.error("Bridge error:", res.status); + } + }) + .catch(err => { + console.error("Bridge connection failed:", err); + }); + }); sendResponse({ status: "sent" }); } diff --git a/extension/manifest.json b/extension/manifest.json index 9c851af..330f9d3 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -7,7 +7,7 @@ "storage" ], "host_permissions": [ - "http://localhost:3000/*" + "http://localhost/*" ], "action": { "default_popup": "popup.html", diff --git a/extension/popup.html b/extension/popup.html index 339aede..cbe48d6 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -67,6 +67,18 @@

Haptic Master

+ +
+
+ Server Port +
+
+
+ +
+
+
+