diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..8ac472c
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,23 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ test:
+ name: swift test
+ runs-on: macos-15
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Select Xcode
+ run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
+
+ - name: Build
+ run: swift build
+
+ - name: Test
+ run: swift test
diff --git a/Sources/TouchpadInputApp/Info.plist b/Sources/TouchpadInputApp/Info.plist
index e08e3f7..14df18a 100644
--- a/Sources/TouchpadInputApp/Info.plist
+++ b/Sources/TouchpadInputApp/Info.plist
@@ -7,6 +7,20 @@
TouchpadInput
CFBundleIdentifier
com.touchpad-input.app
+ CFBundleShortVersionString
+ 1.0.0
+ CFBundleVersion
+ 1
+ CFBundlePackageType
+ APPL
+ CFBundleExecutable
+ TouchpadInputApp
+ LSMinimumSystemVersion
+ 12.0
+ NSPrincipalClass
+ NSApplication
+ NSHighResolutionCapable
+
NSAccessibilityUsageDescription
TouchpadInput needs Accessibility access to inject keystrokes into the frontmost app when system injection mode is enabled.
NSHumanReadableCopyright
diff --git a/Sources/TouchpadInputApp/TouchpadInputApp.entitlements b/Sources/TouchpadInputApp/TouchpadInputApp.entitlements
new file mode 100644
index 0000000..f08fae0
--- /dev/null
+++ b/Sources/TouchpadInputApp/TouchpadInputApp.entitlements
@@ -0,0 +1,22 @@
+
+
+
+
+
+ com.apple.security.app-sandbox
+
+
+
+ com.apple.security.cs.disable-library-validation
+
+
+
diff --git a/Sources/TouchpadInputApp/Views/ContentView.swift b/Sources/TouchpadInputApp/Views/ContentView.swift
index 5d4b724..e3104e6 100644
--- a/Sources/TouchpadInputApp/Views/ContentView.swift
+++ b/Sources/TouchpadInputApp/Views/ContentView.swift
@@ -1,9 +1,16 @@
import SwiftUI
import TouchpadInputCore
+private enum AppMode: String, CaseIterable {
+ case keyboard = "Keyboard"
+ case drawing = "Drawing"
+}
+
struct ContentView: View {
@StateObject private var session = TouchInputSession()
+ @StateObject private var drawSession = DrawingSession()
@StateObject private var calibSession = CalibrationSession()
+ @State private var appMode: AppMode = .keyboard
@State private var showSettings = false
@State private var showCalibration = false
@AppStorage("hasCompletedCalibration") private var hasCompletedCalibration = false
@@ -11,36 +18,40 @@ struct ContentView: View {
var body: some View {
VStack(spacing: 0) {
header
- if showSettings {
- Divider()
- SettingsPanel(session: session, onRecalibrate: {
- calibSession.reset()
- showCalibration = true
- })
- }
- if !session.completions.isEmpty {
+ if appMode == .keyboard {
+ if showSettings {
+ Divider()
+ SettingsPanel(session: session, onRecalibrate: {
+ calibSession.reset()
+ showCalibration = true
+ })
+ }
+ if !session.completions.isEmpty {
+ Divider()
+ AutocompleteBar(completions: session.completions) { word in
+ session.acceptCompletion(word)
+ }
+ }
Divider()
- AutocompleteBar(completions: session.completions) { word in
- session.acceptCompletion(word)
+ HStack(spacing: 0) {
+ TrackpadSurface(
+ fingers: session.liveFingers,
+ isActive: session.isActive,
+ zones: KeyGrid.default.applying(calibration: session.userCalibration).zones,
+ activeModifiers: session.activeModifiers
+ )
+ .padding(16)
+ Divider()
+ FingerTablePanel(fingers: session.liveFingers)
+ .frame(width: 340)
}
- }
- Divider()
- HStack(spacing: 0) {
- TrackpadSurface(
- fingers: session.liveFingers,
- isActive: session.isActive,
- zones: KeyGrid.default.applying(calibration: session.userCalibration).zones,
- activeModifiers: session.activeModifiers
- )
- .padding(16)
Divider()
- FingerTablePanel(fingers: session.liveFingers)
- .frame(width: 340)
+ OutputBufferPanel(text: session.outputBuffer, activeModifiers: session.activeModifiers)
+ Divider()
+ EventLogPanel(entries: session.eventLog)
+ } else {
+ DrawingCanvasView(session: drawSession)
}
- Divider()
- OutputBufferPanel(text: session.outputBuffer, activeModifiers: session.activeModifiers)
- Divider()
- EventLogPanel(entries: session.eventLog)
}
.onAppear {
MultitouchCapture.shared.setupDoubleControlToggle(for: session)
@@ -69,22 +80,37 @@ struct ContentView: View {
Text("Touchpad Diagnostics")
.font(.system(size: 18, weight: .semibold))
modePill
+ Divider().frame(height: 18)
+ Picker("Mode", selection: $appMode) {
+ ForEach(AppMode.allCases, id: \.self) { mode in
+ Text(mode.rawValue).tag(mode)
+ }
+ }
+ .pickerStyle(.segmented)
+ .frame(width: 160)
+ .onChange(of: appMode) { mode in
+ let activeSession: any TouchEventReceiver = mode == .keyboard ? session : drawSession
+ MultitouchCapture.shared.session = activeSession
+ }
Spacer()
- Button(action: { showSettings.toggle() }) {
- Image(systemName: "slider.horizontal.3")
- .foregroundColor(showSettings ? .accentColor : .secondary)
+ if appMode == .keyboard {
+ Button(action: { showSettings.toggle() }) {
+ Image(systemName: "slider.horizontal.3")
+ .foregroundColor(showSettings ? .accentColor : .secondary)
+ }
+ .buttonStyle(.borderless)
+ .help("Toggle stability settings")
+ Button("Clear") { session.clearAll() }
}
- .buttonStyle(.borderless)
- .help("Toggle stability settings")
- Button("Clear") { session.clearAll() }
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
private var modePill: some View {
- Group {
- if session.isActive {
+ let isActive = appMode == .keyboard ? session.isActive : drawSession.isActive
+ return Group {
+ if isActive {
Text("● CAPTURING — double ctrl to stop")
.foregroundColor(.white)
.background(Color.green)
diff --git a/Sources/TouchpadInputApp/Views/DrawingCanvasView.swift b/Sources/TouchpadInputApp/Views/DrawingCanvasView.swift
new file mode 100644
index 0000000..ad2e7f7
--- /dev/null
+++ b/Sources/TouchpadInputApp/Views/DrawingCanvasView.swift
@@ -0,0 +1,184 @@
+// Sources/TouchpadInputApp/Views/DrawingCanvasView.swift
+import SwiftUI
+import TouchpadInputCore
+import AppKit
+
+struct DrawingCanvasView: View {
+ @ObservedObject var session: DrawingSession
+
+ @State private var selectedColor: Color = .black
+ @State private var lineWidth: CGFloat = 2.5
+ private let widthOptions: [CGFloat] = [1.5, 2.5, 4.0, 7.0]
+
+ var body: some View {
+ VStack(spacing: 0) {
+ toolbar
+ Divider()
+ canvas
+ }
+ }
+
+ // MARK: - Toolbar
+
+ private var toolbar: some View {
+ HStack(spacing: 12) {
+ Text("Drawing")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundColor(.secondary)
+
+ Divider().frame(height: 18)
+
+ // Color swatches
+ ForEach([Color.black, .blue, .red, .green, .orange], id: \.self) { color in
+ colorSwatch(color)
+ }
+
+ Divider().frame(height: 18)
+
+ // Line width picker
+ Picker("Width", selection: $lineWidth) {
+ ForEach(widthOptions, id: \.self) { w in
+ Text(widthLabel(w)).tag(w)
+ }
+ }
+ .pickerStyle(.segmented)
+ .frame(width: 160)
+ .onChange(of: lineWidth) { w in
+ session.currentLineWidth = w
+ }
+
+ Spacer()
+
+ Button("Undo") { session.undo() }
+ .disabled(session.strokes.isEmpty)
+ .keyboardShortcut("z", modifiers: .command)
+
+ Button("Clear") { session.clear() }
+ .disabled(session.strokes.isEmpty)
+
+ Button("Export PNG") { exportPNG() }
+ .disabled(session.strokes.isEmpty)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+ }
+
+ private func colorSwatch(_ color: Color) -> some View {
+ Button(action: {
+ selectedColor = color
+ session.currentColor = NSColor(color).cgColor
+ }) {
+ Circle()
+ .fill(color)
+ .frame(width: 18, height: 18)
+ .overlay(
+ Circle()
+ .stroke(selectedColor == color ? Color.primary : Color.clear, lineWidth: 2)
+ )
+ }
+ .buttonStyle(.borderless)
+ }
+
+ private func widthLabel(_ w: CGFloat) -> String {
+ switch w {
+ case 1.5: return "Fine"
+ case 2.5: return "Med"
+ case 4.0: return "Thick"
+ case 7.0: return "Bold"
+ default: return String(format: "%.0f", w)
+ }
+ }
+
+ // MARK: - Canvas
+
+ private var canvas: some View {
+ GeometryReader { geo in
+ ZStack {
+ // Background
+ RoundedRectangle(cornerRadius: 0)
+ .fill(Color.white)
+ RoundedRectangle(cornerRadius: 0)
+ .stroke(
+ session.isActive ? Color.green.opacity(0.4) : Color.secondary.opacity(0.2),
+ lineWidth: session.isActive ? 1.5 : 1
+ )
+
+ // Strokes canvas
+ Canvas { ctx, size in
+ for stroke in session.strokes {
+ drawStroke(stroke, in: ctx, size: size)
+ }
+ }
+
+ // Live finger dots
+ ForEach(session.liveFingers) { finger in
+ Circle()
+ .fill(selectedColor.opacity(0.4))
+ .frame(width: 14, height: 14)
+ .position(
+ x: finger.x * geo.size.width,
+ y: (1 - finger.y) * geo.size.height
+ )
+ }
+
+ if !session.isActive {
+ Text("Double-tap ctrl to start drawing")
+ .font(.system(size: 14))
+ .foregroundColor(.secondary)
+ .padding(8)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 6))
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding(16)
+ }
+
+ // Stroke y is hardware-space (0=bottom, 1=top); Canvas y is screen-space (0=top).
+ private func drawStroke(_ stroke: DrawingStroke, in ctx: GraphicsContext, size: CGSize) {
+ guard stroke.points.count >= 2 else { return }
+
+ let pts = stroke.points.map {
+ CGPoint(x: $0.x * size.width, y: (1 - $0.y) * size.height)
+ }
+
+ var path = Path()
+ path.move(to: pts[0])
+ if pts.count == 2 {
+ path.addLine(to: pts[1])
+ } else {
+ for i in 1.. = []
+
+ public init() {}
+
+ public func update(mtContacts: [MTContact], timestamp: Double) {
+ var currentIDs = Set()
+ var fingers: [FingerState] = []
+
+ for contact in mtContacts {
+ let touchID = contact.identifier
+ currentIDs.insert(touchID)
+
+ let nx = CGFloat(contact.normalized.position.x)
+ let ny = CGFloat(contact.normalized.position.y) // 0=bottom, 1=top (hardware space)
+ let pt = CGPoint(x: nx, y: ny)
+
+ if prevIDs.contains(touchID), let idx = activeIdx[touchID] {
+ strokes[idx].points.append(pt)
+ } else {
+ var stroke = DrawingStroke(color: currentColor, lineWidth: currentLineWidth)
+ stroke.points.append(pt)
+ activeIdx[touchID] = strokes.count
+ strokes.append(stroke)
+ }
+
+ let phase = prevIDs.contains(touchID) ? "moved" : "began"
+ fingers.append(FingerState(
+ id: String(touchID),
+ label: "#\(touchID)",
+ x: nx,
+ y: ny,
+ pressure: CGFloat(min(max(contact.zDensity, 0), 1)),
+ size: CGFloat(contact.size),
+ phase: phase,
+ lastEventTime: timestamp,
+ deltaMsFromPrev: nil
+ ))
+ }
+
+ for endedID in prevIDs.subtracting(currentIDs) {
+ activeIdx.removeValue(forKey: endedID)
+ }
+
+ prevIDs = currentIDs
+ liveFingers = fingers
+ }
+
+ public func undo() {
+ guard !strokes.isEmpty else { return }
+ strokes.removeLast()
+ }
+
+ public func clear() {
+ strokes = []
+ activeIdx = [:]
+ prevIDs = []
+ }
+
+ /// Renders all strokes to an NSImage of the given size.
+ public func renderToImage(size: CGSize) -> NSImage {
+ let image = NSImage(size: size)
+ image.lockFocus()
+ NSColor.white.setFill()
+ NSBezierPath.fill(CGRect(origin: .zero, size: size))
+ for stroke in strokes {
+ renderStroke(stroke, canvasSize: size)
+ }
+ image.unlockFocus()
+ return image
+ }
+
+ // NSImage lockFocus uses AppKit coords: y=0 at bottom, y=height at top.
+ // Our stroke y is hardware-space (0=bottom, 1=top), so no flip needed here.
+ private func renderStroke(_ stroke: DrawingStroke, canvasSize: CGSize) {
+ guard stroke.points.count >= 2 else { return }
+ (NSColor(cgColor: stroke.color) ?? .black).setStroke()
+ let path = NSBezierPath()
+ path.lineWidth = stroke.lineWidth
+ path.lineCapStyle = .round
+ path.lineJoinStyle = .round
+
+ let pts = stroke.points.map {
+ CGPoint(x: $0.x * canvasSize.width, y: $0.y * canvasSize.height)
+ }
+ path.move(to: pts[0])
+ for i in 1..&2; exit 1; }
+
+require_env() {
+ for var in "$@"; do
+ [[ -n "${!var:-}" ]] || error "Required env var \$$var is not set."
+ done
+}
+
+# ── 1. Preflight ──────────────────────────────────────────────────────────────
+
+info "TouchpadInput release script — v${VERSION} (build ${BUILD_NUMBER})"
+
+require_env NOTARIZE_APPLE_ID NOTARIZE_TEAM_ID NOTARIZE_PASSWORD
+
+# Verify signing identity exists in Keychain
+if ! security find-identity -v -p codesigning | grep -q "$SIGN_IDENTITY"; then
+ error "No certificate matching '$SIGN_IDENTITY' found in Keychain."
+fi
+
+# ── 2. Build ──────────────────────────────────────────────────────────────────
+
+info "Building (release)…"
+sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
+cd "$REPO_ROOT"
+swift build -c release 2>&1 | grep -v "^Build complete"
+info "Build complete."
+
+# ── 3. Assemble .app bundle ───────────────────────────────────────────────────
+
+info "Assembling $APP_NAME.app…"
+rm -rf "$STAGING_DIR"
+mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
+
+# Binary
+cp "$BUILD_DIR/$EXECUTABLE" "$CONTENTS/MacOS/$EXECUTABLE"
+
+# Info.plist — stamp version + build number
+cp "$INFO_PLIST" "$CONTENTS/Info.plist"
+/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$CONTENTS/Info.plist"
+/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$CONTENTS/Info.plist"
+
+info "Bundle structure ready."
+
+# ── 4. Code sign ──────────────────────────────────────────────────────────────
+
+info "Signing with '$SIGN_IDENTITY'…"
+codesign \
+ --sign "$SIGN_IDENTITY" \
+ --entitlements "$ENTITLEMENTS" \
+ --options runtime \
+ --timestamp \
+ --force \
+ --deep \
+ "$APP_BUNDLE"
+
+info "Verifying signature…"
+codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE"
+spctl --assess --type execute --verbose "$APP_BUNDLE" 2>&1 || true # may fail pre-notarization
+
+# ── 5. Notarize ───────────────────────────────────────────────────────────────
+
+info "Creating zip for notarization…"
+ZIP_PATH="$STAGING_DIR/${APP_NAME}-${VERSION}.zip"
+ditto -c -k --keepParent "$APP_BUNDLE" "$ZIP_PATH"
+
+info "Submitting to Apple notary service…"
+xcrun notarytool submit "$ZIP_PATH" \
+ --apple-id "$NOTARIZE_APPLE_ID" \
+ --team-id "$NOTARIZE_TEAM_ID" \
+ --password "$NOTARIZE_PASSWORD" \
+ --wait \
+ --output-format plist \
+| tee "$STAGING_DIR/notarytool-result.plist"
+
+STATUS=$(
+ /usr/libexec/PlistBuddy -c "Print :status" "$STAGING_DIR/notarytool-result.plist" 2>/dev/null \
+ || echo "unknown"
+)
+[[ "$STATUS" == "Accepted" ]] || error "Notarization failed (status: $STATUS). Check notarytool-result.plist."
+
+info "Notarization accepted. Stapling ticket…"
+xcrun stapler staple "$APP_BUNDLE"
+
+# ── 6. Create DMG ─────────────────────────────────────────────────────────────
+
+info "Creating DMG…"
+mkdir -p "$OUTPUT_DIR"
+rm -f "$DMG_PATH"
+
+# Temporary uncompressed DMG
+TMP_DMG="$STAGING_DIR/tmp.dmg"
+hdiutil create \
+ -volname "$APP_NAME" \
+ -srcfolder "$APP_BUNDLE" \
+ -ov -format UDRW \
+ "$TMP_DMG"
+
+# Convert to read-only compressed DMG
+hdiutil convert "$TMP_DMG" -format UDZO -o "$DMG_PATH"
+
+# Sign the DMG itself
+codesign \
+ --sign "$SIGN_IDENTITY" \
+ --timestamp \
+ "$DMG_PATH"
+
+info "DMG created: $DMG_PATH"
+
+# ── 7. Done ───────────────────────────────────────────────────────────────────
+
+echo ""
+echo "✓ Release complete: ${APP_NAME}-${VERSION}.dmg"
+echo " Path: $DMG_PATH"
+echo " SHA256: $(shasum -a 256 "$DMG_PATH" | awk '{print $1}')"