diff --git a/.gitignore b/.gitignore index 3c3629e647f5ddf82548912e337bea9826b434af..d948d39f1ce887b5fbf07128678840a352a27e15 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules +.build diff --git a/config/reaper.ts b/config/reaper.ts index bb573403be9bf12b2238b06798db6e61d4bc8d1f..ab0d06b8e6b5c2616034bc9da104dd2d335962e2 100644 --- a/config/reaper.ts +++ b/config/reaper.ts @@ -1,6 +1,6 @@ import * as config from "#config"; -import { Reaper } from "@clo/creative-control/Reaper.ts"; -import { SpeedEditor } from "@clo/creative-control/SpeedEditor.ts"; +import { Reaper } from "@clo/creative-control/Reaper"; +import { SpeedEditor } from "@clo/creative-control/SpeedEditor"; const cfg = config.forApp("com.cockos.reaper", ({ speededitor: se, mac }) => { const reaper = new Reaper(); diff --git a/config/reaper/scripts/generate_recorder_template.lua b/config/reaper/scripts/generate_recorder_template.lua new file mode 100644 index 0000000000000000000000000000000000000000..044a47b4ce65ba0fb723f8cd81fd2104f0340be9 --- /dev/null +++ b/config/reaper/scripts/generate_recorder_template.lua @@ -0,0 +1,34 @@ +-- Generate the Clover Recorder session template. +-- +-- Creates a fresh project with a single record-armed MIDI track listening to +-- "All MIDI Inputs / All Channels" (so any keyboard works), then saves it to +-- the template path. The recorder copies this per session. Open it in REAPER +-- afterwards to add your instrument / tweak settings and re-save — it's yours. +-- +-- Run via: REAPER -nonewinst generate_recorder_template.lua + +local template_path = "/Volumes/Documents/Recorder Template.rpp" + +-- Work in a fresh project tab so we never disturb whatever is already open. +reaper.Main_OnCommand(40859, 0) -- New project tab + +reaper.InsertTrackAtIndex(0, false) +local track = reaper.GetTrack(0, 0) +reaper.GetSetMediaTrackInfo_String(track, "P_NAME", "Improv MIDI", true) +reaper.SetMediaTrackInfo_Value(track, "I_RECARM", 1) +-- MIDI record input: 4096 + (device<<5) + channel; device 62 = all MIDI inputs, +-- channel 0 = omni (all channels). See REAPER API docs for I_RECINPUT. +reaper.SetMediaTrackInfo_Value(track, "I_RECINPUT", 4096 + (62 << 5)) +reaper.SetMediaTrackInfo_Value(track, "I_RECMON", 1) -- input monitoring on + +reaper.Main_SaveProjectEx(0, template_path, 0) + +local readback = reaper.GetMediaTrackInfo_Value(track, "I_RECINPUT") +local log = io.open("/tmp/reaper-template.log", "w") +if log then + log:write(string.format("saved=%s I_RECINPUT=%d\n", template_path, readback)) + log:close() +end + +-- Close the template tab; leave REAPER as it was. +reaper.Main_OnCommand(40860, 0) -- Close current project tab diff --git a/package.json b/package.json index ea002ce071af2c22b9b95c001798c85e537a054e..0ed664cb5c254d52ed28404e29796ace9ddacf90 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,10 @@ "#config": "./src/config.ts" }, "exports": { - "./Mac.ts": "./src/Mac.ts", - "./SpeedEditor.ts": "./src/SpeedEditor.ts", - "./Reaper.ts": "./src/Reaper.ts", - "./Reaper/actions.ts": "./src/Reaper/actions.ts" + "./Mac": "./src/Mac.ts", + "./SpeedEditor": "./src/SpeedEditor.ts", + "./Reaper": "./src/Reaper.ts", + "./Reaper/actions": "./src/Reaper/actions.ts" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/readme.md b/readme.md index 1357e9aea40a21de719896a5e75c9ac2501eee49..dfc7da09984ae980b1a13cfd781b2d5fc936f918 100644 --- a/readme.md +++ b/readme.md @@ -56,7 +56,7 @@ mac.on("app-change", (bundle) => { With the help of an OSC extension, REAPER can be controlled with TypeScript. ```ts -import { Reaper } from "@clo/creative-control/Reaper.ts"; +import { Reaper } from "@clo/creative-control/Reaper"; const reaper = new Reaper(); reaper.on("transport", (transport) => { @@ -96,7 +96,7 @@ with multiple readers conflicting. ```ts import * as config from "#config"; -import { Reaper } from "@clo/creative-control/Reaper.ts"; +import { Reaper } from "@clo/creative-control/Reaper"; export default config.forApp("com.cockos.reaper", ({ speededitor, mac }) => { const reaper = new Reaper(); diff --git a/src/Recorder/build.sh b/src/Recorder/build.sh new file mode 100755 index 0000000000000000000000000000000000000000..418d5f2d87df44237db05042abb8fb6d20dfb43d --- /dev/null +++ b/src/Recorder/build.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Build the Clover Recorder capture engine and wrap it in a minimal .app bundle. +# +# macOS only grants and reliably lists *app bundles* (stable bundle id) under +# Privacy → Screen Recording, so even the headless capture core ships as an app. +# +# Usage: ./build.sh (run on the Mac that will do the recording) +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +ENGINE="$HERE/engine" +DIST="$HERE/dist" +APP="$DIST/Clover Recorder.app" +BUNDLE_ID="org.clover.recorder" +VERSION="0.1.0" + +echo "==> swift build (release)" +( cd "$ENGINE" && swift build -c release ) +BIN="$ENGINE/.build/release/recorder" + +echo "==> assembling $APP" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" +cp "$BIN" "$APP/Contents/MacOS/recorder" + +cat > "$APP/Contents/Info.plist" < + + + CFBundleIdentifier$BUNDLE_ID + CFBundleNameClover Recorder + CFBundleExecutablerecorder + CFBundlePackageTypeAPPL + CFBundleShortVersionString$VERSION + CFBundleVersion$VERSION + LSMinimumSystemVersion14.0 + LSUIElement + NSMicrophoneUsageDescriptionClover Recorder records your microphone for journaling and improv sessions. + NSCameraUsageDescriptionClover Recorder records your webcam for journaling and improv sessions. + +EOF + +# Sign with the stable self-signed identity from the dedicated Clover keychain +# (see setup-signing.sh). This keeps the same designated requirement across +# rebuilds, so the Screen Recording grant survives. Falls back to ad-hoc. +CN="Clover Code Signing" +KC="$HOME/Library/Keychains/clover-signing.keychain-db" +KCPW="${CLOVER_KEYCHAIN_PW:-clover}" + +if [[ -f "$KC" ]] && security find-identity -p codesigning "$KC" 2>/dev/null | grep -q "$CN"; then + security unlock-keychain -p "$KCPW" "$KC" 2>/dev/null || true + echo "==> codesign with stable identity '$CN'" + codesign --force --keychain "$KC" --sign "$CN" --timestamp=none "$APP" +else + echo "==> codesign ad-hoc (run setup-signing.sh once for a stable identity)" + codesign --force --sign - "$APP" +fi + +codesign -dv "$APP" 2>&1 | sed -n '1,4p' || true +echo "==> built: $APP" +echo " binary: $APP/Contents/MacOS/recorder" diff --git a/src/Recorder/engine/Package.swift b/src/Recorder/engine/Package.swift new file mode 100644 index 0000000000000000000000000000000000000000..007f02e269b9862aa0232514046bb5fb85636f59 --- /dev/null +++ b/src/Recorder/engine/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 6.0 +import PackageDescription + +// Capture core for Clover Recorder. +// +// A small ScreenCaptureKit + AVFoundation command-line engine that records any +// combination of displays, the system-audio mix, the microphone, and (later) a +// webcam into one session folder, each stream as its own file, all timestamped +// against the shared mach host clock so the streams can be re-aligned exactly. +// +// No external dependencies, so it builds fully offline. +let package = Package( + name: "recorder", + platforms: [.macOS(.v14)], + targets: [ + .executableTarget( + name: "recorder", + path: "Sources/recorder", + swiftSettings: [.swiftLanguageMode(.v5)] + ) + ] +) diff --git a/src/Recorder/engine/Sources/recorder/CaptureEngine.swift b/src/Recorder/engine/Sources/recorder/CaptureEngine.swift new file mode 100644 index 0000000000000000000000000000000000000000..f36c789e5a135c57e00043e4c3d04a7d33339c88 --- /dev/null +++ b/src/Recorder/engine/Sources/recorder/CaptureEngine.swift @@ -0,0 +1,307 @@ +import AVFoundation +import CoreGraphics +import CoreMedia +import Foundation +import ScreenCaptureKit + +struct RecordConfig { + var outDir: URL + var label: String + var displayIDs: [CGDirectDisplayID] // empty = all + var systemAudio: Bool + var micUID: String? + var cameraUID: String? + var fps: Int + var maxWidth: Int // clamp longest side (HiDPI backing buffers are huge) + var bitsPerPixel: Double + var duration: Double? // auto-stop after N seconds; nil = until signal + var logPath: String? +} + +final class CaptureEngine { + private let cfg: RecordConfig + private var sinks: [RecordingStream] = [] + private var cfrWriters: [CFRVideoWriter] = [] + private var streams: [SCStream] = [] + private var scOutputs: [SCOutput] = [] + private var captureSessions: [AVCaptureSession] = [] + private var avOutputs: [AVOutput] = [] + + private var createdEpoch: Double = 0 + private var hostClockAtStart: Double = 0 + + init(_ cfg: RecordConfig) { self.cfg = cfg } + + func start() async throws { + if let logPath = cfg.logPath { openLogFile(logPath) } + try FileManager.default.createDirectory(at: cfg.outDir, withIntermediateDirectories: true) + createdEpoch = Date().timeIntervalSince1970 + hostClockAtStart = hostSeconds() + + let content = try await SCShareableContent.excludingDesktopWindows( + false, onScreenWindowsOnly: false) + let allDisplays = content.displays.sorted { $0.frame.origin.x < $1.frame.origin.x } + + let chosen: [SCDisplay] + if cfg.displayIDs.isEmpty { + chosen = allDisplays + } else { + chosen = cfg.displayIDs.compactMap { id in allDisplays.first { $0.displayID == id } } + } + if chosen.isEmpty { throw RecorderError("no matching displays to capture") } + + // Capture system audio piggy-backed on the first display's stream. + var systemAudioWriter: StreamWriter? + if cfg.systemAudio { + systemAudioWriter = try makeAudioWriter(name: "desktop", kind: "system-audio") + sinks.append(systemAudioWriter!) + } + + for (index, display) in chosen.enumerated() { + let name = screenName(index: index, count: chosen.count) + let (outW, outH) = outputSize(for: display) + + let writer = try CFRVideoWriter( + url: cfg.outDir.appendingPathComponent("\(name).mov"), + name: name, width: outW, height: outH, fps: cfg.fps, bitrate: screenBitrate(outW, outH)) + writer.displayID = display.displayID + sinks.append(writer) + cfrWriters.append(writer) + + let cfgSC = SCStreamConfiguration() + cfgSC.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(cfg.fps)) + cfgSC.queueDepth = 8 + cfgSC.showsCursor = true + cfgSC.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange + cfgSC.width = outW + cfgSC.height = outH + + let attachAudioHere = (index == 0) && (systemAudioWriter != nil) + if attachAudioHere { + cfgSC.capturesAudio = true + cfgSC.sampleRate = 48_000 + cfgSC.channelCount = 2 + } + + let filter = SCContentFilter(display: display, excludingWindows: []) + let output = SCOutput( + label: name, + onScreen: { [weak writer] sb in writer?.update(sb) }, + onAudio: attachAudioHere + ? { [weak systemAudioWriter] sb in systemAudioWriter?.append(sb) } : nil) + scOutputs.append(output) + + let stream = SCStream(filter: filter, configuration: cfgSC, delegate: output) + try stream.addStreamOutput( + output, type: .screen, + sampleHandlerQueue: DispatchQueue(label: "clover.sc.screen.\(name)")) + if attachAudioHere { + try stream.addStreamOutput( + output, type: .audio, + sampleHandlerQueue: DispatchQueue(label: "clover.sc.audio")) + } + streams.append(stream) + } + + if let micUID = cfg.micUID { + try startAVCapture(audioUID: micUID) + } + if let cameraUID = cfg.cameraUID { + try startAVCapture(videoUID: cameraUID) + } + + for stream in streams { + try await stream.startCapture() + } + // Begin emitting constant-rate frames now that capture is live. + for writer in cfrWriters { + writer.start() + } + logInfo( + "recording \(chosen.count) screen(s)" + + (cfg.systemAudio ? " + desktop" : "") + + (cfg.micUID != nil ? " + mic" : "") + + (cfg.cameraUID != nil ? " + camera" : "")) + } + + func stop() async { + for output in scOutputs { + logInfo( + "SC \(output.label): screen seen \(output.screenSeen) complete \(output.screenComplete)" + + (output.audioSeen > 0 ? " audio \(output.audioSeen)" : "")) + } + for stream in streams { + try? await stream.stopCapture() + } + for session in captureSessions { + session.stopRunning() + } + // Give in-flight buffers a moment to drain before finalizing. + try? await Task.sleep(nanoseconds: 200_000_000) + for sink in sinks { + await sink.finish() + } + writeManifest() + } + + // MARK: writers + + private func screenBitrate(_ width: Int, _ height: Int) -> Int { + max(2_000_000, Int(Double(width * height * cfg.fps) * cfg.bitsPerPixel)) + } + + private func makeVideoWriter(name: String, kind: String, width: Int, height: Int) throws + -> StreamWriter + { + let settings: [String: Any] = [ + AVVideoCodecKey: AVVideoCodecType.hevc, + AVVideoWidthKey: width, + AVVideoHeightKey: height, + AVVideoCompressionPropertiesKey: [ + AVVideoAverageBitRateKey: screenBitrate(width, height), + AVVideoExpectedSourceFrameRateKey: cfg.fps, + AVVideoMaxKeyFrameIntervalKey: cfg.fps * 2, + ], + ] + return try StreamWriter( + url: cfg.outDir.appendingPathComponent("\(name).mov"), + name: name, kind: kind, fileType: .mov, settings: settings, mediaType: .video) + } + + private func makeAudioWriter(name: String, kind: String) throws -> StreamWriter { + // AAC in .m4a: ~256 kbps stereo, transparent for voice/desktop and tiny next + // to the uncompressed PCM we used to write. + let settings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: 48_000, + AVNumberOfChannelsKey: 2, + AVEncoderBitRateKey: 256_000, + ] + return try StreamWriter( + url: cfg.outDir.appendingPathComponent("\(name).m4a"), + name: name, kind: kind, fileType: .m4a, settings: settings, mediaType: .audio) + } + + // MARK: AVCapture (mic + camera) + + private func startAVCapture(audioUID: String? = nil, videoUID: String? = nil) throws { + let session = AVCaptureSession() + session.beginConfiguration() + + if let audioUID { + guard let device = Devices.audioDevice(matching: audioUID) else { + throw RecorderError("audio device not found: \(audioUID)") + } + let input = try AVCaptureDeviceInput(device: device) + guard session.canAddInput(input) else { throw RecorderError("cannot add mic input") } + session.addInput(input) + + let writer = try makeAudioWriter(name: "mic", kind: "mic") + writer.deviceUID = device.uniqueID + sinks.append(writer) + + let out = AVCaptureAudioDataOutput() + let delegate = AVOutput { [weak writer] sb in writer?.append(sb) } + avOutputs.append(delegate) + out.setSampleBufferDelegate(delegate, queue: DispatchQueue(label: "clover.av.mic")) + guard session.canAddOutput(out) else { throw RecorderError("cannot add mic output") } + session.addOutput(out) + } + + if let videoUID { + guard let device = Devices.videoDevice(matching: videoUID) else { + throw RecorderError("camera not found: \(videoUID)") + } + let input = try AVCaptureDeviceInput(device: device) + guard session.canAddInput(input) else { throw RecorderError("cannot add camera input") } + session.addInput(input) + + let camWriter = try makeVideoWriter( + name: "cam", kind: "camera", width: 1920, height: 1080) + camWriter.deviceUID = device.uniqueID + sinks.append(camWriter) + + let out = AVCaptureVideoDataOutput() + let delegate = AVOutput { [weak camWriter] sb in camWriter?.append(sb) } + avOutputs.append(delegate) + out.setSampleBufferDelegate(delegate, queue: DispatchQueue(label: "clover.av.cam")) + guard session.canAddOutput(out) else { throw RecorderError("cannot add camera output") } + session.addOutput(out) + } + + session.commitConfiguration() + session.startRunning() + captureSessions.append(session) + } + + // MARK: helpers + + private func screenName(index: Int, count: Int) -> String { + if count == 2 { return index == 0 ? "screen-left" : "screen-right" } + return "screen-\(index + 1)" + } + + private func outputSize(for display: SCDisplay) -> (Int, Int) { + // Prefer the true framebuffer pixel size; fall back to the points frame. + var pxW = display.width + var pxH = display.height + if let mode = CGDisplayCopyDisplayMode(display.displayID) { + pxW = mode.pixelWidth + pxH = mode.pixelHeight + } + let longest = max(pxW, pxH) + guard longest > cfg.maxWidth, cfg.maxWidth > 0 else { return (even(pxW), even(pxH)) } + let scale = Double(cfg.maxWidth) / Double(longest) + return (even(Int(Double(pxW) * scale)), even(Int(Double(pxH) * scale))) + } + + private func even(_ v: Int) -> Int { v - (v % 2) } + + private func writeManifest() { + let withData = sinks.filter { !$0.firstPTS.isNaN } + let tStart = withData.map { $0.firstPTS }.min() ?? hostClockAtStart + + var streamManifests = sinks.map { sink -> StreamManifest in + var m = sink.manifest() + m.offsetSeconds = m.firstSampleHostSeconds.isNaN ? 0 : (m.firstSampleHostSeconds - tStart) + return m + } + streamManifests.sort { $0.offsetSeconds < $1.offsetSeconds } + + let manifest = SessionManifest( + recorderVersion: recorderVersion, + label: cfg.label, + createdEpoch: createdEpoch, + hostClockAtStart: hostClockAtStart, + tStartHostSeconds: tStart, + streams: streamManifests) + + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(manifest) + try data.write(to: cfg.outDir.appendingPathComponent("sync.json")) + } catch { + logErr("failed to write manifest: \(error)") + } + + // Human-readable summary to stderr. + logInfo("session: \(cfg.outDir.path)") + for m in streamManifests { + let size = fileSize(cfg.outDir.appendingPathComponent(m.file)) + logInfo( + String( + format: " %-13@ %6.2fs %5d frames drop %-3d rep %-4d off %+0.3fs %@", + m.name as NSString, m.durationSeconds, m.frames, m.dropped, m.repeated, + m.offsetSeconds, size as NSString)) + } + } + + private func fileSize(_ url: URL) -> String { + guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let bytes = attrs[.size] as? Int64 + else { return "—" } + let mb = Double(bytes) / 1_048_576 + return String(format: "%.1f MB", mb) + } +} diff --git a/src/Recorder/engine/Sources/recorder/MenubarApp.swift b/src/Recorder/engine/Sources/recorder/MenubarApp.swift new file mode 100644 index 0000000000000000000000000000000000000000..cea0276565c10a56dafbb9ebaba37980b22c4c66 --- /dev/null +++ b/src/Recorder/engine/Sources/recorder/MenubarApp.swift @@ -0,0 +1,488 @@ +import AppKit +import AVFoundation +import Foundation +import SwiftUI + +// MARK: - Entry + +@MainActor +func runMenubar() { + let app = NSApplication.shared + let controller = AppController() + app.delegate = controller + app.setActivationPolicy(.accessory) // menubar only, no Dock icon + app.run() +} + +// MARK: - Controller + +@MainActor +final class AppController: NSObject, NSApplicationDelegate, ObservableObject { + // Settings (persisted) + @Published var destination: String = UserDefaults.standard.string(forKey: "destination") ?? "Sessions" + { + didSet { + // Sessions default to recording REAPER; Journal defaults to not. You can + // still override the checkbox afterwards. + guard destination != oldValue else { return } + reaperMidi = (destination == "Sessions") + } + } + @Published var includeDesktop = UserDefaults.standard.object(forKey: "desktop") as? Bool ?? true + @Published var includeMic = UserDefaults.standard.object(forKey: "mic") as? Bool ?? true + @Published var reaperMidi = UserDefaults.standard.object(forKey: "reaper") as? Bool ?? false + @Published var enabledDisplays: Set = [] + + // Discovered hardware + @Published var displays: [DisplayInfo] = [] + @Published var hasMic = false + @Published var permissionOK = true + + // Live state + @Published var isRecording = false + @Published var elapsed: TimeInterval = 0 + @Published var lastSession: String? + @Published var errorMessage: String? + + private var statusItem: NSStatusItem? + private var popover: NSPopover? + private var engine: CaptureEngine? + private var starting = false + private var startHost: Double = 0 + private var tickTimer: Timer? + private var currentSessionDir: URL? + private var zenithDir: URL? + + // Fast local scratch for video/audio; the NAS archive is the final home. + let tempRoot = URL(fileURLWithPath: "/Volumes/Documents/Temp") + let archiveRoot = URL(fileURLWithPath: "/Volumes/clover/Archive") + let reaperTemplate = URL(fileURLWithPath: "/Volumes/Documents/Recorder Template.rpp") + @Published var statusText: String? + + func applicationDidFinishLaunching(_ notification: Notification) { + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + item.button?.image = NSImage( + systemSymbolName: "record.circle", accessibilityDescription: "Clover Recorder") + item.button?.action = #selector(togglePopover) + item.button?.target = self + statusItem = item + + let pop = NSPopover() + pop.behavior = .transient + pop.contentSize = NSSize(width: 320, height: 460) + pop.contentViewController = NSHostingController(rootView: ContentView(controller: self)) + popover = pop + + Task { await refreshDevices() } + } + + @objc private func togglePopover() { + guard let button = statusItem?.button, let popover else { return } + if popover.isShown { + popover.performClose(nil) + } else { + Task { await refreshDevices() } // re-check displays + mic each time it opens + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + popover.contentViewController?.view.window?.makeKey() + } + } + + func refreshDevices() async { + do { + let devices = try await Devices.discover() + displays = devices.displays + hasMic = !devices.audioInputs.isEmpty + permissionOK = true + // Display IDs change across sleep/wake, so drop stale selections and fall + // back to all current displays if nothing valid remains. + let currentIDs = Set(devices.displays.map { $0.id }) + enabledDisplays.formIntersection(currentIDs) + if enabledDisplays.isEmpty { enabledDisplays = currentIDs } + } catch { + permissionOK = false + errorMessage = "Screen Recording permission needed." + } + } + + func setDisplay(_ id: UInt32, on: Bool) { + if on { enabledDisplays.insert(id) } else { enabledDisplays.remove(id) } + } + + private func persist() { + let d = UserDefaults.standard + d.set(destination, forKey: "destination") + d.set(includeDesktop, forKey: "desktop") + d.set(includeMic, forKey: "mic") + d.set(reaperMidi, forKey: "reaper") + } + + // MARK: recording + + func start() { + guard !isRecording, !starting else { return } + starting = true + persist() + errorMessage = nil + statusText = "Checking zenith…" + Task { + guard await self.ensureZenithMounted() else { + self.starting = false + self.statusText = nil + self.errorMessage = "zenith archive isn't mounted — mount /Volumes/clover and try again." + return + } + await self.refreshDevices() // display IDs shift across sleep/wake + await self.beginRecording() + self.starting = false + } + } + + private func beginRecording() async { + let name = resolveSessionName() + let temp = tempRoot.appendingPathComponent(name) + let zenith = zenithSessionDir(name) + try? FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + + // The final NAS home — created now so REAPER can record straight into it. + var zenithReady: URL? = zenith + do { + try FileManager.default.createDirectory(at: zenith, withIntermediateDirectories: true) + } catch { + errorMessage = "Archive folder unavailable (\(error.localizedDescription)); will keep local copy." + zenithReady = nil + } + + // Record only currently-available displays. + let current = Set(displays.map { $0.id }) + var useDisplays = enabledDisplays.intersection(current) + if useDisplays.isEmpty { useDisplays = current } + + let cfg = RecordConfig( + outDir: temp, + label: name, + displayIDs: Array(useDisplays), + systemAudio: includeDesktop, + micUID: (includeMic && hasMic) ? "default" : nil, + cameraUID: nil, + fps: 30, + maxWidth: 3840, + bitsPerPixel: 0.05, + duration: nil, + logPath: temp.appendingPathComponent("recorder.log").path) + + // Start capture FIRST; only wire up REAPER + UI once it's confirmed live, so + // a failure cleans up instead of leaving empty session folders / a stray + // REAPER project behind. + let engine = CaptureEngine(cfg) + statusText = "Starting…" + do { + try await engine.start() + } catch { + statusText = nil + errorMessage = error.localizedDescription + if "\(error)".contains("declined") { permissionOK = false } + try? FileManager.default.removeItem(at: temp) + if let z = zenithReady { try? FileManager.default.removeItem(at: z) } + return + } + + self.engine = engine + currentSessionDir = temp + zenithDir = zenithReady + if reaperMidi { setupReaper(name: name) } + isRecording = true + elapsed = 0 + startHost = hostSeconds() + statusText = "Recording…" + updateIcon() + startTick() + } + + func stop() { + guard isRecording, let engine else { return } + isRecording = false + stopTick() + updateIcon() + statusText = "Finalizing…" + let temp = currentSessionDir + let zenith = zenithDir + Task { + await engine.stop() + self.engine = nil + guard let temp else { return } + guard let zenith else { + self.lastSession = temp.lastPathComponent + self.statusText = "Saved locally: \(temp.lastPathComponent)" + return + } + self.statusText = "Archiving to zenith…" + guard await self.ensureZenithMounted() else { + self.lastSession = temp.lastPathComponent + self.statusText = "zenith offline — kept local copy" + self.errorMessage = + "zenith not mounted; files remain in /Temp/\(temp.lastPathComponent)." + return + } + let ok = await self.archive(temp: temp, zenith: zenith) + self.lastSession = zenith.lastPathComponent + if ok { + self.statusText = "Saved: \(zenith.lastPathComponent)" + } else { + self.statusText = "Archive failed — kept local copy" + self.errorMessage = "rsync to zenith failed; files remain in /Temp/\(temp.lastPathComponent)." + } + } + } + + func toggle() { isRecording ? stop() : start() } + + private func startTick() { + tickTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + Task { @MainActor in self?.elapsed = hostSeconds() - (self?.startHost ?? 0) } + } + } + private func stopTick() { + tickTimer?.invalidate() + tickTimer = nil + } + + private func updateIcon() { + let name = isRecording ? "stop.circle.fill" : "record.circle" + let image = NSImage(systemSymbolName: name, accessibilityDescription: "Clover Recorder") + if isRecording { + image?.isTemplate = false + statusItem?.button?.contentTintColor = .systemRed + } else { + statusItem?.button?.contentTintColor = nil + } + statusItem?.button?.image = image + } + + /// Session reference is the start time, `YYYY-MM-DD_HH.MM`. If one already + /// exists for this minute (locally or on the NAS), bump forward a minute until + /// it's unique. + private func resolveSessionName() -> String { + var date = Date() + for _ in 0..<240 { + let name = stamp("yyyy-MM-dd_HH.mm", date) + let taken = + FileManager.default.fileExists(atPath: tempRoot.appendingPathComponent(name).path) + || FileManager.default.fileExists(atPath: zenithSessionDir(name).path) + if !taken { return name } + date = date.addingTimeInterval(60) + } + return stamp("yyyy-MM-dd_HH.mm.ss") // fallback, should never hit + } + + func copyReference() { + guard let ref = lastSession else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(ref, forType: .string) + statusText = "Copied \(ref)" + } + + private func zenithSessionDir(_ name: String) -> URL { + archiveRoot.appendingPathComponent(stamp("yyyy")) + .appendingPathComponent(destination) + .appendingPathComponent(name) + } + + // MARK: REAPER + + private func setupReaper(name: String) { + guard let zenith = zenithDir else { + errorMessage = "Can't set up REAPER without the archive folder." + return + } + guard FileManager.default.fileExists(atPath: reaperTemplate.path) else { + errorMessage = "REAPER template not found at \(reaperTemplate.path)" + return + } + let reaperDir = zenith.appendingPathComponent("reaper") + let project = reaperDir.appendingPathComponent("\(name).rpp") + do { + try FileManager.default.createDirectory(at: reaperDir, withIntermediateDirectories: true) + try FileManager.default.copyItem(at: reaperTemplate, to: project) + } catch { + errorMessage = "REAPER project setup failed: \(error.localizedDescription)" + return + } + // Open it; you drive transport. REAPER records its media next to the project + // (the final NAS location), so nothing needs repathing afterwards. + let open = Process() + open.executableURL = URL(fileURLWithPath: "/usr/bin/open") + open.arguments = ["-a", "REAPER", project.path] + try? open.run() + } + + // MARK: Archive + + /// rsync the local scratch session into its NAS home, then remove the scratch + /// copy on success. Runs off the main actor so the UI stays responsive. + private func archive(temp: URL, zenith: URL) async -> Bool { + await withCheckedContinuation { (cont: CheckedContinuation) in + DispatchQueue.global(qos: .utility).async { + let p = Process() + p.executableURL = URL(fileURLWithPath: "/usr/bin/rsync") + p.arguments = ["-a", "--exclude=recorder.log", temp.path + "/", zenith.path + "/"] + do { + try p.run() + p.waitUntilExit() + } catch { + cont.resume(returning: false) + return + } + let ok = p.terminationStatus == 0 + if ok { try? FileManager.default.removeItem(at: temp) } + cont.resume(returning: ok) + } + } + } + + // MARK: zenith mount + + private func zenithMounted() -> Bool { + let vols = FileManager.default.mountedVolumeURLs( + includingResourceValuesForKeys: nil, options: [.skipHiddenVolumes]) ?? [] + return vols.contains { $0.path == "/Volumes/clover" } + && FileManager.default.fileExists(atPath: archiveRoot.path) + } + + /// Ensure the zenith SMB share is mounted, attempting to mount it with saved + /// keychain credentials and polling briefly if it wasn't. + private func ensureZenithMounted() async -> Bool { + if zenithMounted() { return true } + await withCheckedContinuation { (cont: CheckedContinuation) in + DispatchQueue.global(qos: .utility).async { + let p = Process() + p.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + p.arguments = ["-e", "mount volume \"smb://clo@zenith.local/clover\""] + try? p.run() + p.waitUntilExit() + cont.resume() + } + } + for _ in 0..<12 { + if zenithMounted() { return true } + try? await Task.sleep(nanoseconds: 500_000_000) + } + return zenithMounted() + } + + private func stamp(_ format: String, _ date: Date = Date()) -> String { + let f = DateFormatter() + f.dateFormat = format + return f.string(from: date) + } +} + +// MARK: - View + +struct ContentView: View { + @ObservedObject var controller: AppController + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Clover Recorder").font(.headline) + Spacer() + if controller.isRecording { + Text(timeString(controller.elapsed)) + .font(.system(.body, design: .monospaced)).foregroundStyle(.red) + } + } + + if !controller.permissionOK { + Label("Screen Recording permission needed", systemImage: "exclamationmark.triangle") + .font(.caption).foregroundStyle(.orange) + } + + Picker("", selection: $controller.destination) { + Text("Sessions").tag("Sessions") + Text("Journal").tag("Journal") + } + .pickerStyle(.segmented) + .disabled(controller.isRecording) + + Divider() + + VStack(alignment: .leading, spacing: 6) { + Text("Capture").font(.caption).foregroundStyle(.secondary) + ForEach(controller.displays, id: \.id) { d in + Toggle( + displayLabel(d), + isOn: Binding( + get: { controller.enabledDisplays.contains(d.id) }, + set: { controller.setDisplay(d.id, on: $0) }) + ).disabled(controller.isRecording) + } + Toggle("Desktop audio", isOn: $controller.includeDesktop).disabled(controller.isRecording) + if controller.hasMic { + Toggle("Microphone", isOn: $controller.includeMic).disabled(controller.isRecording) + } else { + Label("No microphone connected", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + } + Toggle("REAPER (MIDI)", isOn: $controller.reaperMidi).disabled(controller.isRecording) + } + .toggleStyle(.checkbox) + + Spacer() + + if let last = controller.lastSession, !controller.isRecording { + Button(action: { controller.copyReference() }) { + HStack(spacing: 6) { + Image(systemName: "doc.on.doc") + Text(last).font(.system(.caption, design: .monospaced)) + } + } + .buttonStyle(.borderless) + .help("Copy session reference to clipboard") + } + if let status = controller.statusText { + Text(status).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + if let err = controller.errorMessage { + Text(err).font(.caption).foregroundStyle(.red).lineLimit(2) + } + + VStack(spacing: 4) { + Button(action: { controller.toggle() }) { + HStack(spacing: 6) { + if !controller.isRecording && !controller.hasMic { + Image(systemName: "exclamationmark.triangle.fill") + } + Text(controller.isRecording ? "Stop" : "Start Recording") + } + .frame(maxWidth: .infinity) + } + .controlSize(.large) + .tint(controller.isRecording ? .red : (controller.hasMic ? .accentColor : .yellow)) + .disabled(!controller.permissionOK && !controller.isRecording) + + if !controller.isRecording && !controller.hasMic { + Text("Will record without microphone") + .font(.caption2).foregroundStyle(.yellow) + } + } + + HStack { + Button("Quit") { NSApp.terminate(nil) }.font(.caption) + Spacer() + } + } + .padding(14) + .frame(width: 320) + } + + private func displayLabel(_ d: DisplayInfo) -> String { + let idx = controller.displays.firstIndex(where: { $0.id == d.id }) ?? 0 + let side = controller.displays.count == 2 ? (idx == 0 ? " (left)" : " (right)") : "" + return "Screen \(idx + 1)\(side) — \(d.width)×\(d.height)" + } + + private func timeString(_ t: TimeInterval) -> String { + let s = Int(t) + return String(format: "%02d:%02d", s / 60, s % 60) + } +} diff --git a/src/Recorder/engine/Sources/recorder/Recorder.swift b/src/Recorder/engine/Sources/recorder/Recorder.swift new file mode 100644 index 0000000000000000000000000000000000000000..853f14c2cd1a9e9157c18887523e400cbca598f7 --- /dev/null +++ b/src/Recorder/engine/Sources/recorder/Recorder.swift @@ -0,0 +1,512 @@ +import AVFoundation +import CoreGraphics +import CoreMedia +import Foundation +import ScreenCaptureKit + +let recorderVersion = "0.1.0" + +// MARK: - Logging + +// Optional log file so diagnostics survive launch methods that discard +// stdout/stderr (e.g. `open` / LaunchServices). +var logFile: FileHandle? + +func openLogFile(_ path: String) { + FileManager.default.createFile(atPath: path, contents: nil) + logFile = FileHandle(forWritingAtPath: path) +} + +private func emit(_ message: String) { + let line = "[recorder] " + message + "\n" + FileHandle.standardError.write(Data(line.utf8)) + if let logFile { + logFile.write(Data(line.utf8)) + } +} + +func logErr(_ message: String) { emit(message) } +func logInfo(_ message: String) { emit(message) } + +// MARK: - Host clock + +/// Seconds on the mach host clock — the same clock ScreenCaptureKit and +/// AVCapture stamp their sample buffers with, so values are directly comparable +/// across every stream. +func hostSeconds() -> Double { + CMTimeGetSeconds(CMClockGetTime(CMClockGetHostTimeClock())) +} + +// MARK: - Device discovery + +struct DisplayInfo: Codable { + let id: UInt32 + let width: Int + let height: Int + let x: Int + let y: Int +} + +struct DeviceInfo: Codable { + let uid: String + let name: String +} + +struct DiscoveredDevices: Codable { + let displays: [DisplayInfo] + let cameras: [DeviceInfo] + let audioInputs: [DeviceInfo] +} + +enum Devices { + static func discover() async throws -> DiscoveredDevices { + let content = try await SCShareableContent.excludingDesktopWindows( + false, onScreenWindowsOnly: false) + + let displays = + content.displays + .sorted { $0.frame.origin.x < $1.frame.origin.x } + .map { + DisplayInfo( + id: $0.displayID, + width: Int($0.frame.width), + height: Int($0.frame.height), + x: Int($0.frame.origin.x), + y: Int($0.frame.origin.y)) + } + + let cameras = AVCaptureDevice.DiscoverySession( + deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera], + mediaType: .video, position: .unspecified + ).devices.map { DeviceInfo(uid: $0.uniqueID, name: $0.localizedName) } + + let audioInputs = AVCaptureDevice.DiscoverySession( + deviceTypes: [.microphone, .external], + mediaType: .audio, position: .unspecified + ).devices.map { DeviceInfo(uid: $0.uniqueID, name: $0.localizedName) } + + return DiscoveredDevices(displays: displays, cameras: cameras, audioInputs: audioInputs) + } + + static func audioDevice(matching wanted: String) -> AVCaptureDevice? { + let devices = AVCaptureDevice.DiscoverySession( + deviceTypes: [.microphone, .external], + mediaType: .audio, position: .unspecified + ).devices + if wanted == "default" { + return AVCaptureDevice.default(for: .audio) ?? devices.first + } + return devices.first { $0.uniqueID == wanted || $0.localizedName == wanted } + } + + static func videoDevice(matching wanted: String) -> AVCaptureDevice? { + let devices = AVCaptureDevice.DiscoverySession( + deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera], + mediaType: .video, position: .unspecified + ).devices + if wanted == "default" { + return AVCaptureDevice.default(for: .video) ?? devices.first + } + return devices.first { $0.uniqueID == wanted || $0.localizedName == wanted } + } +} + +// MARK: - Manifest + +struct StreamManifest: Codable { + let name: String + let file: String + let kind: String + var displayID: UInt32? + var deviceUID: String? + var width: Int? + var height: Int? + var fps: Int? + let firstSampleHostSeconds: Double + let lastSampleHostSeconds: Double + let durationSeconds: Double + let frames: Int + let dropped: Int // real drops (input not ready / append failed) + let repeated: Int // CFR frames re-emitted to hold the rate on a static screen + var offsetSeconds: Double +} + +struct SessionManifest: Codable { + let recorderVersion: String + let label: String + let createdEpoch: Double + let hostClockAtStart: Double + let tStartHostSeconds: Double + let streams: [StreamManifest] +} + +// MARK: - Stream sink protocol + +/// Anything that records one stream to one file and reports where it sat on the +/// shared host clock, so the engine can align and summarize them uniformly. +protocol RecordingStream: AnyObject { + var firstPTS: Double { get } + func finish() async + func manifest() -> StreamManifest +} + +// MARK: - Stream writer (passthrough: audio + camera) + +/// Owns one AVAssetWriter + input and turns a flow of CMSampleBuffers into one +/// file, lazily starting the writer session on the first buffer and recording +/// that buffer's host-clock timestamp for later alignment. +final class StreamWriter: RecordingStream { + let name: String + let kind: String + let url: URL + var displayID: UInt32? + var deviceUID: String? + var width: Int? + var height: Int? + var fps: Int? + + private let writer: AVAssetWriter + private let input: AVAssetWriterInput + private let lock = NSLock() + + private(set) var firstPTS: Double = .nan + private(set) var lastPTS: Double = .nan + private(set) var frames = 0 + private(set) var dropped = 0 + private var started = false + private var failed = false + + init( + url: URL, name: String, kind: String, fileType: AVFileType, + settings: [String: Any], mediaType: AVMediaType + ) throws { + self.url = url + self.name = name + self.kind = kind + self.writer = try AVAssetWriter(outputURL: url, fileType: fileType) + self.input = AVAssetWriterInput(mediaType: mediaType, outputSettings: settings) + self.input.expectsMediaDataInRealTime = true + if writer.canAdd(input) { + writer.add(input) + } else { + throw RecorderError("cannot add \(mediaType.rawValue) input for \(name)") + } + } + + func append(_ sb: CMSampleBuffer) { + lock.lock() + defer { lock.unlock() } + if failed { return } + + let pts = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sb)) + + if !started { + guard writer.startWriting() else { + failed = true + logErr("\(name): startWriting failed: \(writer.error?.localizedDescription ?? "?")") + return + } + writer.startSession(atSourceTime: CMSampleBufferGetPresentationTimeStamp(sb)) + firstPTS = pts + started = true + } + + if input.isReadyForMoreMediaData { + if input.append(sb) { + frames += 1 + lastPTS = pts + } else { + dropped += 1 + if writer.status == .failed { + failed = true + logErr("\(name): append failed: \(writer.error?.localizedDescription ?? "?")") + } + } + } else { + dropped += 1 + } + } + + func finish() async { + lock.lock() + let didStart = started + if didStart { input.markAsFinished() } + lock.unlock() + + guard didStart else { + logErr("\(name): no samples captured, nothing written") + return + } + await withCheckedContinuation { (cont: CheckedContinuation) in + writer.finishWriting { cont.resume() } + } + if writer.status == .failed { + logErr("\(name): finishWriting failed: \(writer.error?.localizedDescription ?? "?")") + } + } + + func manifest() -> StreamManifest { + StreamManifest( + name: name, + file: url.lastPathComponent, + kind: kind, + displayID: displayID, + deviceUID: deviceUID, + width: width, + height: height, + fps: fps, + firstSampleHostSeconds: firstPTS, + lastSampleHostSeconds: lastPTS, + durationSeconds: (firstPTS.isNaN || lastPTS.isNaN) ? 0 : (lastPTS - firstPTS), + frames: frames, + dropped: dropped, + repeated: 0, + offsetSeconds: 0) + } +} + +struct RecorderError: Error, CustomStringConvertible, LocalizedError { + let description: String + init(_ description: String) { self.description = description } + var errorDescription: String? { description } +} + +// MARK: - Constant-frame-rate video writer (screens) + +/// ScreenCaptureKit only delivers a frame when the screen changes, so a raw +/// passthrough yields a variable, sparse frame rate whose duration ends at the +/// last change rather than at the stop time — which would desync against audio. +/// +/// This writer decouples capture from encoding: SCOutput hands every fresh +/// frame to `update(_:)`, and an independent timer emits the most-recent frame +/// at the target rate, timestamped on the host clock. The result stays dense, +/// its duration matches wall-clock, and it never drifts against the audio. +final class CFRVideoWriter: RecordingStream { + let name: String + let kind = "screen" + let url: URL + var displayID: UInt32? + let width: Int + let height: Int + let fps: Int + + private let writer: AVAssetWriter + private let input: AVAssetWriterInput + private let adaptor: AVAssetWriterInputPixelBufferAdaptor + private let queue = DispatchQueue(label: "clover.cfr") + private let lock = NSLock() + + private var latest: CVPixelBuffer? + private var lastEmitted: CVPixelBuffer? + private var timer: DispatchSourceTimer? + + private(set) var firstPTS = Double.nan + private var lastPTS = Double.nan + private(set) var frames = 0 + private(set) var repeated = 0 + private(set) var dropped = 0 + private var started = false + private var failed = false + + init(url: URL, name: String, width: Int, height: Int, fps: Int, bitrate: Int) throws { + self.url = url + self.name = name + self.width = width + self.height = height + self.fps = fps + + let settings: [String: Any] = [ + AVVideoCodecKey: AVVideoCodecType.hevc, + AVVideoWidthKey: width, + AVVideoHeightKey: height, + AVVideoCompressionPropertiesKey: [ + AVVideoAverageBitRateKey: bitrate, + AVVideoExpectedSourceFrameRateKey: fps, + AVVideoMaxKeyFrameIntervalKey: fps * 2, + ], + ] + writer = try AVAssetWriter(outputURL: url, fileType: .mov) + input = AVAssetWriterInput(mediaType: .video, outputSettings: settings) + input.expectsMediaDataInRealTime = true + adaptor = AVAssetWriterInputPixelBufferAdaptor( + assetWriterInput: input, + sourcePixelBufferAttributes: [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + kCVPixelBufferWidthKey as String: width, + kCVPixelBufferHeightKey as String: height, + ]) + guard writer.canAdd(input) else { throw RecorderError("cannot add video input for \(name)") } + writer.add(input) + } + + /// Latest frame from ScreenCaptureKit; retained until replaced (SCK won't + /// recycle a buffer we still hold, so no copy is needed). + func update(_ sb: CMSampleBuffer) { + guard let pb = CMSampleBufferGetImageBuffer(sb) else { return } + lock.lock() + latest = pb + lock.unlock() + } + + func start() { + let interval = 1.0 / Double(fps) + let t = DispatchSource.makeTimerSource(queue: queue) + t.schedule(deadline: .now() + interval, repeating: interval, leeway: .milliseconds(2)) + t.setEventHandler { [weak self] in self?.tick() } + timer = t + t.resume() + } + + private func tick() { + lock.lock() + let pb = latest + lock.unlock() + guard let pb else { return } // no frame captured yet + + let pts = hostSeconds() + if !started { + guard writer.startWriting() else { + failed = true + logErr("\(name): startWriting failed: \(writer.error?.localizedDescription ?? "?")") + return + } + writer.startSession(atSourceTime: CMTime(seconds: pts, preferredTimescale: 1_000_000)) + firstPTS = pts + started = true + } + guard !failed else { return } + guard input.isReadyForMoreMediaData else { + dropped += 1 + return + } + let time = CMTime(seconds: pts, preferredTimescale: 1_000_000) + if adaptor.append(pb, withPresentationTime: time) { + frames += 1 + lastPTS = pts + if pb === lastEmitted { repeated += 1 } + lastEmitted = pb + } else { + dropped += 1 + if writer.status == .failed { + failed = true + logErr("\(name): append failed: \(writer.error?.localizedDescription ?? "?")") + } + } + } + + func finish() async { + timer?.cancel() + timer = nil + // Drain the timer queue so no tick races with finalization. + await withCheckedContinuation { (cont: CheckedContinuation) in + queue.async { cont.resume() } + } + guard started else { + logErr("\(name): no frames captured, nothing written") + return + } + input.markAsFinished() + await withCheckedContinuation { (cont: CheckedContinuation) in + writer.finishWriting { cont.resume() } + } + if writer.status == .failed { + logErr("\(name): finishWriting failed: \(writer.error?.localizedDescription ?? "?")") + } + } + + func manifest() -> StreamManifest { + StreamManifest( + name: name, + file: url.lastPathComponent, + kind: kind, + displayID: displayID, + deviceUID: nil, + width: width, + height: height, + fps: fps, + firstSampleHostSeconds: firstPTS, + lastSampleHostSeconds: lastPTS, + durationSeconds: (firstPTS.isNaN || lastPTS.isNaN) ? 0 : (lastPTS - firstPTS), + frames: frames, + dropped: dropped, + repeated: repeated, + offsetSeconds: 0) + } +} + +// MARK: - Sample delegates + +final class SCOutput: NSObject, SCStreamOutput, SCStreamDelegate { + let label: String + private let onScreen: (CMSampleBuffer) -> Void + private let onAudio: ((CMSampleBuffer) -> Void)? + + private let counterLock = NSLock() + private(set) var screenSeen = 0 + private(set) var screenComplete = 0 + private(set) var audioSeen = 0 + + init( + label: String, onScreen: @escaping (CMSampleBuffer) -> Void, + onAudio: ((CMSampleBuffer) -> Void)? + ) { + self.label = label + self.onScreen = onScreen + self.onAudio = onAudio + } + + func stream( + _ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, + of type: SCStreamOutputType + ) { + switch type { + case .screen: + counterLock.lock() + screenSeen += 1 + counterLock.unlock() + guard CMSampleBufferGetImageBuffer(sampleBuffer) != nil, Self.isComplete(sampleBuffer) else { + return + } + counterLock.lock() + screenComplete += 1 + counterLock.unlock() + onScreen(sampleBuffer) + case .audio: + counterLock.lock() + audioSeen += 1 + counterLock.unlock() + onAudio?(sampleBuffer) + default: + break + } + } + + func stream(_ stream: SCStream, didStopWithError error: Error) { + logErr("SCStream stopped with error: \(error.localizedDescription)") + } + + static func isComplete(_ sb: CMSampleBuffer) -> Bool { + guard + let arr = CMSampleBufferGetSampleAttachmentsArray(sb, createIfNecessary: false) + as? [[SCStreamFrameInfo: Any]], + let info = arr.first, + let raw = info[.status] as? Int, + let status = SCFrameStatus(rawValue: raw) + else { return false } + return status == .complete + } +} + +final class AVOutput: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, + AVCaptureAudioDataOutputSampleBufferDelegate +{ + private let cb: (CMSampleBuffer) -> Void + init(_ cb: @escaping (CMSampleBuffer) -> Void) { self.cb = cb } + + func captureOutput( + _ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) { + cb(sampleBuffer) + } +} diff --git a/src/Recorder/engine/Sources/recorder/main.swift b/src/Recorder/engine/Sources/recorder/main.swift new file mode 100644 index 0000000000000000000000000000000000000000..e54f028b526eec8b098c3393be7b547ff0792ce8 --- /dev/null +++ b/src/Recorder/engine/Sources/recorder/main.swift @@ -0,0 +1,167 @@ +import AVFoundation +import CoreGraphics +import Dispatch +import Foundation + +// Clover Recorder capture core. +// +// recorder list +// recorder record --out DIR [options] +// +// record options: +// --label NAME session label (default "session") +// --screen ID capture this display (repeatable; default: all) +// --system-audio capture the system-audio mix into desktop.m4a +// --mic UID|default capture this audio input into mic.m4a +// --camera UID|default capture this camera into cam.mov +// --fps N frame rate (default 30) +// --max-width N clamp the longest screen side, px (default 3840; 0 = native) +// --bpp F HEVC bits per pixel-frame (default 0.05) +// --duration S auto-stop after S seconds (otherwise runs until SIGINT/SIGTERM) + +func fail(_ message: String) -> Never { + logErr(message) + exit(1) +} + +func runList() { + let sem = DispatchSemaphore(value: 0) + var exitCode: Int32 = 0 + Task { + do { + let devices = try await Devices.discover() + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(devices) + FileHandle.standardOutput.write(data) + FileHandle.standardOutput.write(Data("\n".utf8)) + } catch { + logErr("list failed: \(error.localizedDescription)") + logErr("(Screen Recording permission may not be granted to this binary yet.)") + exitCode = 1 + } + sem.signal() + } + sem.wait() + exit(exitCode) +} + +func parseRecordConfig(_ args: [String]) -> RecordConfig { + var outDir: URL? + var label = "session" + var displayIDs: [CGDirectDisplayID] = [] + var systemAudio = false + var micUID: String? + var cameraUID: String? + var fps = 30 + var maxWidth = 3840 + var bpp = 0.05 + var duration: Double? + var logPath: String? + + var i = 0 + func next(_ flag: String) -> String { + i += 1 + guard i < args.count else { fail("missing value for \(flag)") } + return args[i] + } + + while i < args.count { + let arg = args[i] + switch arg { + case "--out": outDir = URL(fileURLWithPath: next(arg)) + case "--label": label = next(arg) + case "--screen": + guard let id = UInt32(next(arg)) else { fail("--screen expects a numeric display id") } + displayIDs.append(id) + case "--system-audio": systemAudio = true + case "--mic": micUID = next(arg) + case "--camera": cameraUID = next(arg) + case "--fps": fps = Int(next(arg)) ?? 30 + case "--max-width": maxWidth = Int(next(arg)) ?? 3840 + case "--bpp": bpp = Double(next(arg)) ?? 0.05 + case "--duration": duration = Double(next(arg)) + case "--log": logPath = next(arg) + default: fail("unknown option: \(arg)") + } + i += 1 + } + + guard let outDir else { fail("--out DIR is required") } + return RecordConfig( + outDir: outDir, label: label, displayIDs: displayIDs, systemAudio: systemAudio, + micUID: micUID, cameraUID: cameraUID, fps: fps, maxWidth: maxWidth, bitsPerPixel: bpp, + duration: duration, logPath: logPath) +} + +func runRecord(_ args: [String]) { + let cfg = parseRecordConfig(args) + if let logPath = cfg.logPath { openLogFile(logPath) } + logInfo("recorder \(recorderVersion) starting: \(cfg.outDir.path)") + let engine = CaptureEngine(cfg) + + // Never block the main thread: ScreenCaptureKit delivers start/stop + // completions on the main queue, so we keep main free via dispatchMain() + // and drive everything from background queues / Tasks. + let stopGuard = StopOnce() + func triggerStop() { + guard stopGuard.begin() else { return } + logInfo("stopping…") + Task { + await engine.stop() + exit(0) + } + } + + Task { + do { + try await engine.start() + } catch { + logErr("start failed: \(error.localizedDescription)") + exit(1) + } + } + + // Stop on SIGINT / SIGTERM, or after --duration. + let sigQueue = DispatchQueue(label: "clover.signals") + var sources: [DispatchSourceSignal] = [] + for sig in [SIGINT, SIGTERM] { + signal(sig, SIG_IGN) + let src = DispatchSource.makeSignalSource(signal: sig, queue: sigQueue) + src.setEventHandler { triggerStop() } + src.resume() + sources.append(src) + } + if let duration = cfg.duration { + sigQueue.asyncAfter(deadline: .now() + duration) { triggerStop() } + } + signalSources = sources // keep alive + + dispatchMain() +} + +/// One-shot guard so duration + signal can't both run finalize. +final class StopOnce { + private let lock = NSLock() + private var started = false + func begin() -> Bool { + lock.lock() + defer { lock.unlock() } + if started { return false } + started = true + return true + } +} + +var signalSources: [DispatchSourceSignal] = [] + +// Entry point. With no args (or `menubar`) it runs as a menubar app; with +// `record`/`list` it runs headless for SSH/scripting. Same signed binary either +// way, so the one Screen Recording grant covers both. +let argv = Array(CommandLine.arguments.dropFirst()) +switch argv.first { +case "list": runList() +case "record": runRecord(Array(argv.dropFirst())) +case nil, "menubar": MainActor.assumeIsolated { runMenubar() } +default: fail("unknown command: \(argv.first ?? "")") +} diff --git a/src/Recorder/setup-signing.sh b/src/Recorder/setup-signing.sh new file mode 100755 index 0000000000000000000000000000000000000000..a6a9650f30ccaeff3bf3ddc023f7e4386fb5bb8d --- /dev/null +++ b/src/Recorder/setup-signing.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# One-time: create a stable self-signed code-signing identity in a dedicated +# keychain so the Screen Recording grant survives rebuilds. +# +# Why a dedicated keychain (not login): it can be created, unlocked, and +# imported into entirely over SSH with a known password — no GUI, no touching +# your login keychain. TCC keys the Screen Recording grant on the app's +# *designated requirement* (bundle id + cert leaf), which stays identical across +# rebuilds, so you grant once and never get re-prompted. +# +# The cert is self-signed and untrusted; that's fine — Gatekeeper is bypassed +# for locally-built, non-quarantined apps, and TCC matching doesn't need trust. +# +# Safe to re-run; it's idempotent. The keychain password is local-only and has +# nothing to do with your macOS login password. +set -euo pipefail + +CN="Clover Code Signing" +KC="$HOME/Library/Keychains/clover-signing.keychain-db" +KCPW="${CLOVER_KEYCHAIN_PW:-clover}" +P12="$HOME/.clover-code-signing.p12" # backup so the identity survives keychain loss + +ensure_searchlist() { + local existing + existing=$(security list-keychains -d user | sed -e 's/^ *//' -e 's/"//g') + case "$existing" in + *clover-signing*) ;; + *) security list-keychains -d user -s "$KC" $existing ;; + esac +} + +if [[ -f "$KC" ]] && security find-identity -p codesigning "$KC" 2>/dev/null | grep -q "$CN"; then + security unlock-keychain -p "$KCPW" "$KC" 2>/dev/null || true + ensure_searchlist + echo "✅ '$CN' already present in $KC" + exit 0 +fi + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +if [[ -f "$P12" ]]; then + echo "==> reusing saved identity from $P12 (keeps the same TCC requirement)" + cp "$P12" "$TMP/cs.p12" +else + echo "==> generating new self-signed code-signing certificate" + cat > "$TMP/cs.conf" </dev/null 2>&1 + openssl pkcs12 -export -inkey "$TMP/cs.key" -in "$TMP/cs.crt" -out "$TMP/cs.p12" \ + -passout pass:clover -name "$CN" >/dev/null 2>&1 + cp "$TMP/cs.p12" "$P12" + chmod 600 "$P12" +fi + +echo "==> (re)creating dedicated keychain $KC" +security delete-keychain "$KC" 2>/dev/null || true +security create-keychain -p "$KCPW" "$KC" +security set-keychain-settings "$KC" # no auto-lock timeout +security unlock-keychain -p "$KCPW" "$KC" +security import "$TMP/cs.p12" -k "$KC" -P clover -A -T /usr/bin/codesign +security set-key-partition-list -S apple-tool:,apple:,unsigned: -s -k "$KCPW" "$KC" >/dev/null 2>&1 || true +ensure_searchlist + +echo +if security find-identity -p codesigning "$KC" | grep -q "$CN"; then + echo "✅ '$CN' ready in $KC. build.sh will sign with it automatically." +else + echo "⚠️ identity not found after setup — check the output above." + exit 1 +fi