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}')"