Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions HapticMaster/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
}
}

Expand Down
28 changes: 13 additions & 15 deletions HapticMaster/HapticEngine.swift
Original file line number Diff line number Diff line change
@@ -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)")
Expand Down Expand Up @@ -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)
}
}
12 changes: 11 additions & 1 deletion HapticMaster/HapticMasterApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
100 changes: 63 additions & 37 deletions HapticMaster/WebServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {}
}
}
5 changes: 3 additions & 2 deletions HapticMaster/build_with_icon.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/bin/bash
set -e

# Configuration
APP_NAME="Haptic Master"
Expand All @@ -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
Expand Down
39 changes: 20 additions & 19 deletions extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
}
Expand Down
2 changes: 1 addition & 1 deletion extension/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"storage"
],
"host_permissions": [
"http://localhost:3000/*"
"http://localhost/*"
],
"action": {
"default_popup": "popup.html",
Expand Down
12 changes: 12 additions & 0 deletions extension/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ <h1>Haptic Master</h1>
</div>
</div>

<!-- Server Card -->
<div class="card glass">
<div class="card-header">
Server Port
</div>
<div class="control-group">
<div class="pattern-row">
<input type="number" id="portInput" min="1024" max="65535" placeholder="3000" class="port-input">
</div>
</div>
</div>

<footer>
<a href="https://chamuka.is-a.dev" target="_blank">Connect</a>
<span class="separator"> </span>
Expand Down
Loading