diff --git a/HapticMaster/ContentView.swift b/HapticMaster/ContentView.swift index 26102b3..81bcc02 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 = 3000 + @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("3000", 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..3f4424b 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": 3000 + ]) + 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..6a9d208 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 = 3000 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..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 +
+
+
+ +
+
+
+