authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-17 10:56:04-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-20 12:13:35-07:00
logdc68ac0c426180e0b724d0b1acfe8bd30c199c36
tree819109c642682b309c090060de44905df24282e6
parentc7e1a06c390d968e00840aceead85c877a0f2abb
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat: specialized recording software for improv/journal

Assisted-by: Claude:claude-opus-4.7

12 files changed, 1682 insertions(+), 8 deletions(-)

.gitignore+1
......@@ -1 +1,2 @@
11node_modules
2.build
config/reaper.ts+2-2
......@@ -1,6 +1,6 @@
11import * as config from "#config";
2import { Reaper } from "@clo/creative-control/Reaper.ts";
3import { SpeedEditor } from "@clo/creative-control/SpeedEditor.ts";
2import { Reaper } from "@clo/creative-control/Reaper";
3import { SpeedEditor } from "@clo/creative-control/SpeedEditor";
44
55const cfg = config.forApp("com.cockos.reaper", ({ speededitor: se, mac }) => {
66 const reaper = new Reaper();
config/reaper/scripts/generate_recorder_template.lua created+34
......@@ -0,0 +1,34 @@
1-- Generate the Clover Recorder session template.
2--
3-- Creates a fresh project with a single record-armed MIDI track listening to
4-- "All MIDI Inputs / All Channels" (so any keyboard works), then saves it to
5-- the template path. The recorder copies this per session. Open it in REAPER
6-- afterwards to add your instrument / tweak settings and re-save — it's yours.
7--
8-- Run via: REAPER -nonewinst generate_recorder_template.lua
9
10local template_path = "/Volumes/Documents/Recorder Template.rpp"
11
12-- Work in a fresh project tab so we never disturb whatever is already open.
13reaper.Main_OnCommand(40859, 0) -- New project tab
14
15reaper.InsertTrackAtIndex(0, false)
16local track = reaper.GetTrack(0, 0)
17reaper.GetSetMediaTrackInfo_String(track, "P_NAME", "Improv MIDI", true)
18reaper.SetMediaTrackInfo_Value(track, "I_RECARM", 1)
19-- MIDI record input: 4096 + (device<<5) + channel; device 62 = all MIDI inputs,
20-- channel 0 = omni (all channels). See REAPER API docs for I_RECINPUT.
21reaper.SetMediaTrackInfo_Value(track, "I_RECINPUT", 4096 + (62 << 5))
22reaper.SetMediaTrackInfo_Value(track, "I_RECMON", 1) -- input monitoring on
23
24reaper.Main_SaveProjectEx(0, template_path, 0)
25
26local readback = reaper.GetMediaTrackInfo_Value(track, "I_RECINPUT")
27local log = io.open("/tmp/reaper-template.log", "w")
28if log then
29 log:write(string.format("saved=%s I_RECINPUT=%d\n", template_path, readback))
30 log:close()
31end
32
33-- Close the template tab; leave REAPER as it was.
34reaper.Main_OnCommand(40860, 0) -- Close current project tab
package.json+4-4
......@@ -18,10 +18,10 @@
1818 "#config": "./src/config.ts"
1919 },
2020 "exports": {
21 "./Mac.ts": "./src/Mac.ts",
22 "./SpeedEditor.ts": "./src/SpeedEditor.ts",
23 "./Reaper.ts": "./src/Reaper.ts",
24 "./Reaper/actions.ts": "./src/Reaper/actions.ts"
21 "./Mac": "./src/Mac.ts",
22 "./SpeedEditor": "./src/SpeedEditor.ts",
23 "./Reaper": "./src/Reaper.ts",
24 "./Reaper/actions": "./src/Reaper/actions.ts"
2525 },
2626 "pnpm": {
2727 "onlyBuiltDependencies": [
readme.md+2-2
......@@ -56,7 +56,7 @@ mac.on("app-change", (bundle) => {
5656With the help of an OSC extension, REAPER can be controlled with TypeScript.
5757
5858```ts
59import { Reaper } from "@clo/creative-control/Reaper.ts";
59import { Reaper } from "@clo/creative-control/Reaper";
6060
6161const reaper = new Reaper();
6262reaper.on("transport", (transport) => {
......@@ -96,7 +96,7 @@ with multiple readers conflicting.
9696
9797```ts
9898import * as config from "#config";
99import { Reaper } from "@clo/creative-control/Reaper.ts";
99import { Reaper } from "@clo/creative-control/Reaper";
100100
101101export default config.forApp("com.cockos.reaper", ({ speededitor, mac }) => {
102102 const reaper = new Reaper();
src/Recorder/build.sh created+61
......@@ -0,0 +1,61 @@
1#!/usr/bin/env bash
2# Build the Clover Recorder capture engine and wrap it in a minimal .app bundle.
3#
4# macOS only grants and reliably lists *app bundles* (stable bundle id) under
5# Privacy → Screen Recording, so even the headless capture core ships as an app.
6#
7# Usage: ./build.sh (run on the Mac that will do the recording)
8set -euo pipefail
9
10HERE="$(cd "$(dirname "$0")" && pwd)"
11ENGINE="$HERE/engine"
12DIST="$HERE/dist"
13APP="$DIST/Clover Recorder.app"
14BUNDLE_ID="org.clover.recorder"
15VERSION="0.1.0"
16
17echo "==> swift build (release)"
18( cd "$ENGINE" && swift build -c release )
19BIN="$ENGINE/.build/release/recorder"
20
21echo "==> assembling $APP"
22rm -rf "$APP"
23mkdir -p "$APP/Contents/MacOS"
24cp "$BIN" "$APP/Contents/MacOS/recorder"
25
26cat > "$APP/Contents/Info.plist" <<EOF
27<?xml version="1.0" encoding="UTF-8"?>
28<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
29<plist version="1.0"><dict>
30 <key>CFBundleIdentifier</key><string>$BUNDLE_ID</string>
31 <key>CFBundleName</key><string>Clover Recorder</string>
32 <key>CFBundleExecutable</key><string>recorder</string>
33 <key>CFBundlePackageType</key><string>APPL</string>
34 <key>CFBundleShortVersionString</key><string>$VERSION</string>
35 <key>CFBundleVersion</key><string>$VERSION</string>
36 <key>LSMinimumSystemVersion</key><string>14.0</string>
37 <key>LSUIElement</key><true/>
38 <key>NSMicrophoneUsageDescription</key><string>Clover Recorder records your microphone for journaling and improv sessions.</string>
39 <key>NSCameraUsageDescription</key><string>Clover Recorder records your webcam for journaling and improv sessions.</string>
40</dict></plist>
41EOF
42
43# Sign with the stable self-signed identity from the dedicated Clover keychain
44# (see setup-signing.sh). This keeps the same designated requirement across
45# rebuilds, so the Screen Recording grant survives. Falls back to ad-hoc.
46CN="Clover Code Signing"
47KC="$HOME/Library/Keychains/clover-signing.keychain-db"
48KCPW="${CLOVER_KEYCHAIN_PW:-clover}"
49
50if [[ -f "$KC" ]] && security find-identity -p codesigning "$KC" 2>/dev/null | grep -q "$CN"; then
51 security unlock-keychain -p "$KCPW" "$KC" 2>/dev/null || true
52 echo "==> codesign with stable identity '$CN'"
53 codesign --force --keychain "$KC" --sign "$CN" --timestamp=none "$APP"
54else
55 echo "==> codesign ad-hoc (run setup-signing.sh once for a stable identity)"
56 codesign --force --sign - "$APP"
57fi
58
59codesign -dv "$APP" 2>&1 | sed -n '1,4p' || true
60echo "==> built: $APP"
61echo " binary: $APP/Contents/MacOS/recorder"
src/Recorder/engine/Package.swift created+22
......@@ -0,0 +1,22 @@
1// swift-tools-version: 6.0
2import PackageDescription
3
4// Capture core for Clover Recorder.
5//
6// A small ScreenCaptureKit + AVFoundation command-line engine that records any
7// combination of displays, the system-audio mix, the microphone, and (later) a
8// webcam into one session folder, each stream as its own file, all timestamped
9// against the shared mach host clock so the streams can be re-aligned exactly.
10//
11// No external dependencies, so it builds fully offline.
12let package = Package(
13 name: "recorder",
14 platforms: [.macOS(.v14)],
15 targets: [
16 .executableTarget(
17 name: "recorder",
18 path: "Sources/recorder",
19 swiftSettings: [.swiftLanguageMode(.v5)]
20 )
21 ]
22)
src/Recorder/engine/Sources/recorder/CaptureEngine.swift created+307
......@@ -0,0 +1,307 @@
1import AVFoundation
2import CoreGraphics
3import CoreMedia
4import Foundation
5import ScreenCaptureKit
6
7struct RecordConfig {
8 var outDir: URL
9 var label: String
10 var displayIDs: [CGDirectDisplayID] // empty = all
11 var systemAudio: Bool
12 var micUID: String?
13 var cameraUID: String?
14 var fps: Int
15 var maxWidth: Int // clamp longest side (HiDPI backing buffers are huge)
16 var bitsPerPixel: Double
17 var duration: Double? // auto-stop after N seconds; nil = until signal
18 var logPath: String?
19}
20
21final class CaptureEngine {
22 private let cfg: RecordConfig
23 private var sinks: [RecordingStream] = []
24 private var cfrWriters: [CFRVideoWriter] = []
25 private var streams: [SCStream] = []
26 private var scOutputs: [SCOutput] = []
27 private var captureSessions: [AVCaptureSession] = []
28 private var avOutputs: [AVOutput] = []
29
30 private var createdEpoch: Double = 0
31 private var hostClockAtStart: Double = 0
32
33 init(_ cfg: RecordConfig) { self.cfg = cfg }
34
35 func start() async throws {
36 if let logPath = cfg.logPath { openLogFile(logPath) }
37 try FileManager.default.createDirectory(at: cfg.outDir, withIntermediateDirectories: true)
38 createdEpoch = Date().timeIntervalSince1970
39 hostClockAtStart = hostSeconds()
40
41 let content = try await SCShareableContent.excludingDesktopWindows(
42 false, onScreenWindowsOnly: false)
43 let allDisplays = content.displays.sorted { $0.frame.origin.x < $1.frame.origin.x }
44
45 let chosen: [SCDisplay]
46 if cfg.displayIDs.isEmpty {
47 chosen = allDisplays
48 } else {
49 chosen = cfg.displayIDs.compactMap { id in allDisplays.first { $0.displayID == id } }
50 }
51 if chosen.isEmpty { throw RecorderError("no matching displays to capture") }
52
53 // Capture system audio piggy-backed on the first display's stream.
54 var systemAudioWriter: StreamWriter?
55 if cfg.systemAudio {
56 systemAudioWriter = try makeAudioWriter(name: "desktop", kind: "system-audio")
57 sinks.append(systemAudioWriter!)
58 }
59
60 for (index, display) in chosen.enumerated() {
61 let name = screenName(index: index, count: chosen.count)
62 let (outW, outH) = outputSize(for: display)
63
64 let writer = try CFRVideoWriter(
65 url: cfg.outDir.appendingPathComponent("\(name).mov"),
66 name: name, width: outW, height: outH, fps: cfg.fps, bitrate: screenBitrate(outW, outH))
67 writer.displayID = display.displayID
68 sinks.append(writer)
69 cfrWriters.append(writer)
70
71 let cfgSC = SCStreamConfiguration()
72 cfgSC.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(cfg.fps))
73 cfgSC.queueDepth = 8
74 cfgSC.showsCursor = true
75 cfgSC.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
76 cfgSC.width = outW
77 cfgSC.height = outH
78
79 let attachAudioHere = (index == 0) && (systemAudioWriter != nil)
80 if attachAudioHere {
81 cfgSC.capturesAudio = true
82 cfgSC.sampleRate = 48_000
83 cfgSC.channelCount = 2
84 }
85
86 let filter = SCContentFilter(display: display, excludingWindows: [])
87 let output = SCOutput(
88 label: name,
89 onScreen: { [weak writer] sb in writer?.update(sb) },
90 onAudio: attachAudioHere
91 ? { [weak systemAudioWriter] sb in systemAudioWriter?.append(sb) } : nil)
92 scOutputs.append(output)
93
94 let stream = SCStream(filter: filter, configuration: cfgSC, delegate: output)
95 try stream.addStreamOutput(
96 output, type: .screen,
97 sampleHandlerQueue: DispatchQueue(label: "clover.sc.screen.\(name)"))
98 if attachAudioHere {
99 try stream.addStreamOutput(
100 output, type: .audio,
101 sampleHandlerQueue: DispatchQueue(label: "clover.sc.audio"))
102 }
103 streams.append(stream)
104 }
105
106 if let micUID = cfg.micUID {
107 try startAVCapture(audioUID: micUID)
108 }
109 if let cameraUID = cfg.cameraUID {
110 try startAVCapture(videoUID: cameraUID)
111 }
112
113 for stream in streams {
114 try await stream.startCapture()
115 }
116 // Begin emitting constant-rate frames now that capture is live.
117 for writer in cfrWriters {
118 writer.start()
119 }
120 logInfo(
121 "recording \(chosen.count) screen(s)"
122 + (cfg.systemAudio ? " + desktop" : "")
123 + (cfg.micUID != nil ? " + mic" : "")
124 + (cfg.cameraUID != nil ? " + camera" : ""))
125 }
126
127 func stop() async {
128 for output in scOutputs {
129 logInfo(
130 "SC \(output.label): screen seen \(output.screenSeen) complete \(output.screenComplete)"
131 + (output.audioSeen > 0 ? " audio \(output.audioSeen)" : ""))
132 }
133 for stream in streams {
134 try? await stream.stopCapture()
135 }
136 for session in captureSessions {
137 session.stopRunning()
138 }
139 // Give in-flight buffers a moment to drain before finalizing.
140 try? await Task.sleep(nanoseconds: 200_000_000)
141 for sink in sinks {
142 await sink.finish()
143 }
144 writeManifest()
145 }
146
147 // MARK: writers
148
149 private func screenBitrate(_ width: Int, _ height: Int) -> Int {
150 max(2_000_000, Int(Double(width * height * cfg.fps) * cfg.bitsPerPixel))
151 }
152
153 private func makeVideoWriter(name: String, kind: String, width: Int, height: Int) throws
154 -> StreamWriter
155 {
156 let settings: [String: Any] = [
157 AVVideoCodecKey: AVVideoCodecType.hevc,
158 AVVideoWidthKey: width,
159 AVVideoHeightKey: height,
160 AVVideoCompressionPropertiesKey: [
161 AVVideoAverageBitRateKey: screenBitrate(width, height),
162 AVVideoExpectedSourceFrameRateKey: cfg.fps,
163 AVVideoMaxKeyFrameIntervalKey: cfg.fps * 2,
164 ],
165 ]
166 return try StreamWriter(
167 url: cfg.outDir.appendingPathComponent("\(name).mov"),
168 name: name, kind: kind, fileType: .mov, settings: settings, mediaType: .video)
169 }
170
171 private func makeAudioWriter(name: String, kind: String) throws -> StreamWriter {
172 // AAC in .m4a: ~256 kbps stereo, transparent for voice/desktop and tiny next
173 // to the uncompressed PCM we used to write.
174 let settings: [String: Any] = [
175 AVFormatIDKey: kAudioFormatMPEG4AAC,
176 AVSampleRateKey: 48_000,
177 AVNumberOfChannelsKey: 2,
178 AVEncoderBitRateKey: 256_000,
179 ]
180 return try StreamWriter(
181 url: cfg.outDir.appendingPathComponent("\(name).m4a"),
182 name: name, kind: kind, fileType: .m4a, settings: settings, mediaType: .audio)
183 }
184
185 // MARK: AVCapture (mic + camera)
186
187 private func startAVCapture(audioUID: String? = nil, videoUID: String? = nil) throws {
188 let session = AVCaptureSession()
189 session.beginConfiguration()
190
191 if let audioUID {
192 guard let device = Devices.audioDevice(matching: audioUID) else {
193 throw RecorderError("audio device not found: \(audioUID)")
194 }
195 let input = try AVCaptureDeviceInput(device: device)
196 guard session.canAddInput(input) else { throw RecorderError("cannot add mic input") }
197 session.addInput(input)
198
199 let writer = try makeAudioWriter(name: "mic", kind: "mic")
200 writer.deviceUID = device.uniqueID
201 sinks.append(writer)
202
203 let out = AVCaptureAudioDataOutput()
204 let delegate = AVOutput { [weak writer] sb in writer?.append(sb) }
205 avOutputs.append(delegate)
206 out.setSampleBufferDelegate(delegate, queue: DispatchQueue(label: "clover.av.mic"))
207 guard session.canAddOutput(out) else { throw RecorderError("cannot add mic output") }
208 session.addOutput(out)
209 }
210
211 if let videoUID {
212 guard let device = Devices.videoDevice(matching: videoUID) else {
213 throw RecorderError("camera not found: \(videoUID)")
214 }
215 let input = try AVCaptureDeviceInput(device: device)
216 guard session.canAddInput(input) else { throw RecorderError("cannot add camera input") }
217 session.addInput(input)
218
219 let camWriter = try makeVideoWriter(
220 name: "cam", kind: "camera", width: 1920, height: 1080)
221 camWriter.deviceUID = device.uniqueID
222 sinks.append(camWriter)
223
224 let out = AVCaptureVideoDataOutput()
225 let delegate = AVOutput { [weak camWriter] sb in camWriter?.append(sb) }
226 avOutputs.append(delegate)
227 out.setSampleBufferDelegate(delegate, queue: DispatchQueue(label: "clover.av.cam"))
228 guard session.canAddOutput(out) else { throw RecorderError("cannot add camera output") }
229 session.addOutput(out)
230 }
231
232 session.commitConfiguration()
233 session.startRunning()
234 captureSessions.append(session)
235 }
236
237 // MARK: helpers
238
239 private func screenName(index: Int, count: Int) -> String {
240 if count == 2 { return index == 0 ? "screen-left" : "screen-right" }
241 return "screen-\(index + 1)"
242 }
243
244 private func outputSize(for display: SCDisplay) -> (Int, Int) {
245 // Prefer the true framebuffer pixel size; fall back to the points frame.
246 var pxW = display.width
247 var pxH = display.height
248 if let mode = CGDisplayCopyDisplayMode(display.displayID) {
249 pxW = mode.pixelWidth
250 pxH = mode.pixelHeight
251 }
252 let longest = max(pxW, pxH)
253 guard longest > cfg.maxWidth, cfg.maxWidth > 0 else { return (even(pxW), even(pxH)) }
254 let scale = Double(cfg.maxWidth) / Double(longest)
255 return (even(Int(Double(pxW) * scale)), even(Int(Double(pxH) * scale)))
256 }
257
258 private func even(_ v: Int) -> Int { v - (v % 2) }
259
260 private func writeManifest() {
261 let withData = sinks.filter { !$0.firstPTS.isNaN }
262 let tStart = withData.map { $0.firstPTS }.min() ?? hostClockAtStart
263
264 var streamManifests = sinks.map { sink -> StreamManifest in
265 var m = sink.manifest()
266 m.offsetSeconds = m.firstSampleHostSeconds.isNaN ? 0 : (m.firstSampleHostSeconds - tStart)
267 return m
268 }
269 streamManifests.sort { $0.offsetSeconds < $1.offsetSeconds }
270
271 let manifest = SessionManifest(
272 recorderVersion: recorderVersion,
273 label: cfg.label,
274 createdEpoch: createdEpoch,
275 hostClockAtStart: hostClockAtStart,
276 tStartHostSeconds: tStart,
277 streams: streamManifests)
278
279 do {
280 let encoder = JSONEncoder()
281 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
282 let data = try encoder.encode(manifest)
283 try data.write(to: cfg.outDir.appendingPathComponent("sync.json"))
284 } catch {
285 logErr("failed to write manifest: \(error)")
286 }
287
288 // Human-readable summary to stderr.
289 logInfo("session: \(cfg.outDir.path)")
290 for m in streamManifests {
291 let size = fileSize(cfg.outDir.appendingPathComponent(m.file))
292 logInfo(
293 String(
294 format: " %-13@ %6.2fs %5d frames drop %-3d rep %-4d off %+0.3fs %@",
295 m.name as NSString, m.durationSeconds, m.frames, m.dropped, m.repeated,
296 m.offsetSeconds, size as NSString))
297 }
298 }
299
300 private func fileSize(_ url: URL) -> String {
301 guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
302 let bytes = attrs[.size] as? Int64
303 else { return "—" }
304 let mb = Double(bytes) / 1_048_576
305 return String(format: "%.1f MB", mb)
306 }
307}
src/Recorder/engine/Sources/recorder/MenubarApp.swift created+488
......@@ -0,0 +1,488 @@
1import AppKit
2import AVFoundation
3import Foundation
4import SwiftUI
5
6// MARK: - Entry
7
8@MainActor
9func runMenubar() {
10 let app = NSApplication.shared
11 let controller = AppController()
12 app.delegate = controller
13 app.setActivationPolicy(.accessory) // menubar only, no Dock icon
14 app.run()
15}
16
17// MARK: - Controller
18
19@MainActor
20final class AppController: NSObject, NSApplicationDelegate, ObservableObject {
21 // Settings (persisted)
22 @Published var destination: String = UserDefaults.standard.string(forKey: "destination") ?? "Sessions"
23 {
24 didSet {
25 // Sessions default to recording REAPER; Journal defaults to not. You can
26 // still override the checkbox afterwards.
27 guard destination != oldValue else { return }
28 reaperMidi = (destination == "Sessions")
29 }
30 }
31 @Published var includeDesktop = UserDefaults.standard.object(forKey: "desktop") as? Bool ?? true
32 @Published var includeMic = UserDefaults.standard.object(forKey: "mic") as? Bool ?? true
33 @Published var reaperMidi = UserDefaults.standard.object(forKey: "reaper") as? Bool ?? false
34 @Published var enabledDisplays: Set<UInt32> = []
35
36 // Discovered hardware
37 @Published var displays: [DisplayInfo] = []
38 @Published var hasMic = false
39 @Published var permissionOK = true
40
41 // Live state
42 @Published var isRecording = false
43 @Published var elapsed: TimeInterval = 0
44 @Published var lastSession: String?
45 @Published var errorMessage: String?
46
47 private var statusItem: NSStatusItem?
48 private var popover: NSPopover?
49 private var engine: CaptureEngine?
50 private var starting = false
51 private var startHost: Double = 0
52 private var tickTimer: Timer?
53 private var currentSessionDir: URL?
54 private var zenithDir: URL?
55
56 // Fast local scratch for video/audio; the NAS archive is the final home.
57 let tempRoot = URL(fileURLWithPath: "/Volumes/Documents/Temp")
58 let archiveRoot = URL(fileURLWithPath: "/Volumes/clover/Archive")
59 let reaperTemplate = URL(fileURLWithPath: "/Volumes/Documents/Recorder Template.rpp")
60 @Published var statusText: String?
61
62 func applicationDidFinishLaunching(_ notification: Notification) {
63 let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
64 item.button?.image = NSImage(
65 systemSymbolName: "record.circle", accessibilityDescription: "Clover Recorder")
66 item.button?.action = #selector(togglePopover)
67 item.button?.target = self
68 statusItem = item
69
70 let pop = NSPopover()
71 pop.behavior = .transient
72 pop.contentSize = NSSize(width: 320, height: 460)
73 pop.contentViewController = NSHostingController(rootView: ContentView(controller: self))
74 popover = pop
75
76 Task { await refreshDevices() }
77 }
78
79 @objc private func togglePopover() {
80 guard let button = statusItem?.button, let popover else { return }
81 if popover.isShown {
82 popover.performClose(nil)
83 } else {
84 Task { await refreshDevices() } // re-check displays + mic each time it opens
85 popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
86 popover.contentViewController?.view.window?.makeKey()
87 }
88 }
89
90 func refreshDevices() async {
91 do {
92 let devices = try await Devices.discover()
93 displays = devices.displays
94 hasMic = !devices.audioInputs.isEmpty
95 permissionOK = true
96 // Display IDs change across sleep/wake, so drop stale selections and fall
97 // back to all current displays if nothing valid remains.
98 let currentIDs = Set(devices.displays.map { $0.id })
99 enabledDisplays.formIntersection(currentIDs)
100 if enabledDisplays.isEmpty { enabledDisplays = currentIDs }
101 } catch {
102 permissionOK = false
103 errorMessage = "Screen Recording permission needed."
104 }
105 }
106
107 func setDisplay(_ id: UInt32, on: Bool) {
108 if on { enabledDisplays.insert(id) } else { enabledDisplays.remove(id) }
109 }
110
111 private func persist() {
112 let d = UserDefaults.standard
113 d.set(destination, forKey: "destination")
114 d.set(includeDesktop, forKey: "desktop")
115 d.set(includeMic, forKey: "mic")
116 d.set(reaperMidi, forKey: "reaper")
117 }
118
119 // MARK: recording
120
121 func start() {
122 guard !isRecording, !starting else { return }
123 starting = true
124 persist()
125 errorMessage = nil
126 statusText = "Checking zenith…"
127 Task {
128 guard await self.ensureZenithMounted() else {
129 self.starting = false
130 self.statusText = nil
131 self.errorMessage = "zenith archive isn't mounted — mount /Volumes/clover and try again."
132 return
133 }
134 await self.refreshDevices() // display IDs shift across sleep/wake
135 await self.beginRecording()
136 self.starting = false
137 }
138 }
139
140 private func beginRecording() async {
141 let name = resolveSessionName()
142 let temp = tempRoot.appendingPathComponent(name)
143 let zenith = zenithSessionDir(name)
144 try? FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true)
145
146 // The final NAS home — created now so REAPER can record straight into it.
147 var zenithReady: URL? = zenith
148 do {
149 try FileManager.default.createDirectory(at: zenith, withIntermediateDirectories: true)
150 } catch {
151 errorMessage = "Archive folder unavailable (\(error.localizedDescription)); will keep local copy."
152 zenithReady = nil
153 }
154
155 // Record only currently-available displays.
156 let current = Set(displays.map { $0.id })
157 var useDisplays = enabledDisplays.intersection(current)
158 if useDisplays.isEmpty { useDisplays = current }
159
160 let cfg = RecordConfig(
161 outDir: temp,
162 label: name,
163 displayIDs: Array(useDisplays),
164 systemAudio: includeDesktop,
165 micUID: (includeMic && hasMic) ? "default" : nil,
166 cameraUID: nil,
167 fps: 30,
168 maxWidth: 3840,
169 bitsPerPixel: 0.05,
170 duration: nil,
171 logPath: temp.appendingPathComponent("recorder.log").path)
172
173 // Start capture FIRST; only wire up REAPER + UI once it's confirmed live, so
174 // a failure cleans up instead of leaving empty session folders / a stray
175 // REAPER project behind.
176 let engine = CaptureEngine(cfg)
177 statusText = "Starting…"
178 do {
179 try await engine.start()
180 } catch {
181 statusText = nil
182 errorMessage = error.localizedDescription
183 if "\(error)".contains("declined") { permissionOK = false }
184 try? FileManager.default.removeItem(at: temp)
185 if let z = zenithReady { try? FileManager.default.removeItem(at: z) }
186 return
187 }
188
189 self.engine = engine
190 currentSessionDir = temp
191 zenithDir = zenithReady
192 if reaperMidi { setupReaper(name: name) }
193 isRecording = true
194 elapsed = 0
195 startHost = hostSeconds()
196 statusText = "Recording…"
197 updateIcon()
198 startTick()
199 }
200
201 func stop() {
202 guard isRecording, let engine else { return }
203 isRecording = false
204 stopTick()
205 updateIcon()
206 statusText = "Finalizing…"
207 let temp = currentSessionDir
208 let zenith = zenithDir
209 Task {
210 await engine.stop()
211 self.engine = nil
212 guard let temp else { return }
213 guard let zenith else {
214 self.lastSession = temp.lastPathComponent
215 self.statusText = "Saved locally: \(temp.lastPathComponent)"
216 return
217 }
218 self.statusText = "Archiving to zenith…"
219 guard await self.ensureZenithMounted() else {
220 self.lastSession = temp.lastPathComponent
221 self.statusText = "zenith offline — kept local copy"
222 self.errorMessage =
223 "zenith not mounted; files remain in /Temp/\(temp.lastPathComponent)."
224 return
225 }
226 let ok = await self.archive(temp: temp, zenith: zenith)
227 self.lastSession = zenith.lastPathComponent
228 if ok {
229 self.statusText = "Saved: \(zenith.lastPathComponent)"
230 } else {
231 self.statusText = "Archive failed — kept local copy"
232 self.errorMessage = "rsync to zenith failed; files remain in /Temp/\(temp.lastPathComponent)."
233 }
234 }
235 }
236
237 func toggle() { isRecording ? stop() : start() }
238
239 private func startTick() {
240 tickTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
241 Task { @MainActor in self?.elapsed = hostSeconds() - (self?.startHost ?? 0) }
242 }
243 }
244 private func stopTick() {
245 tickTimer?.invalidate()
246 tickTimer = nil
247 }
248
249 private func updateIcon() {
250 let name = isRecording ? "stop.circle.fill" : "record.circle"
251 let image = NSImage(systemSymbolName: name, accessibilityDescription: "Clover Recorder")
252 if isRecording {
253 image?.isTemplate = false
254 statusItem?.button?.contentTintColor = .systemRed
255 } else {
256 statusItem?.button?.contentTintColor = nil
257 }
258 statusItem?.button?.image = image
259 }
260
261 /// Session reference is the start time, `YYYY-MM-DD_HH.MM`. If one already
262 /// exists for this minute (locally or on the NAS), bump forward a minute until
263 /// it's unique.
264 private func resolveSessionName() -> String {
265 var date = Date()
266 for _ in 0..<240 {
267 let name = stamp("yyyy-MM-dd_HH.mm", date)
268 let taken =
269 FileManager.default.fileExists(atPath: tempRoot.appendingPathComponent(name).path)
270 || FileManager.default.fileExists(atPath: zenithSessionDir(name).path)
271 if !taken { return name }
272 date = date.addingTimeInterval(60)
273 }
274 return stamp("yyyy-MM-dd_HH.mm.ss") // fallback, should never hit
275 }
276
277 func copyReference() {
278 guard let ref = lastSession else { return }
279 NSPasteboard.general.clearContents()
280 NSPasteboard.general.setString(ref, forType: .string)
281 statusText = "Copied \(ref)"
282 }
283
284 private func zenithSessionDir(_ name: String) -> URL {
285 archiveRoot.appendingPathComponent(stamp("yyyy"))
286 .appendingPathComponent(destination)
287 .appendingPathComponent(name)
288 }
289
290 // MARK: REAPER
291
292 private func setupReaper(name: String) {
293 guard let zenith = zenithDir else {
294 errorMessage = "Can't set up REAPER without the archive folder."
295 return
296 }
297 guard FileManager.default.fileExists(atPath: reaperTemplate.path) else {
298 errorMessage = "REAPER template not found at \(reaperTemplate.path)"
299 return
300 }
301 let reaperDir = zenith.appendingPathComponent("reaper")
302 let project = reaperDir.appendingPathComponent("\(name).rpp")
303 do {
304 try FileManager.default.createDirectory(at: reaperDir, withIntermediateDirectories: true)
305 try FileManager.default.copyItem(at: reaperTemplate, to: project)
306 } catch {
307 errorMessage = "REAPER project setup failed: \(error.localizedDescription)"
308 return
309 }
310 // Open it; you drive transport. REAPER records its media next to the project
311 // (the final NAS location), so nothing needs repathing afterwards.
312 let open = Process()
313 open.executableURL = URL(fileURLWithPath: "/usr/bin/open")
314 open.arguments = ["-a", "REAPER", project.path]
315 try? open.run()
316 }
317
318 // MARK: Archive
319
320 /// rsync the local scratch session into its NAS home, then remove the scratch
321 /// copy on success. Runs off the main actor so the UI stays responsive.
322 private func archive(temp: URL, zenith: URL) async -> Bool {
323 await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
324 DispatchQueue.global(qos: .utility).async {
325 let p = Process()
326 p.executableURL = URL(fileURLWithPath: "/usr/bin/rsync")
327 p.arguments = ["-a", "--exclude=recorder.log", temp.path + "/", zenith.path + "/"]
328 do {
329 try p.run()
330 p.waitUntilExit()
331 } catch {
332 cont.resume(returning: false)
333 return
334 }
335 let ok = p.terminationStatus == 0
336 if ok { try? FileManager.default.removeItem(at: temp) }
337 cont.resume(returning: ok)
338 }
339 }
340 }
341
342 // MARK: zenith mount
343
344 private func zenithMounted() -> Bool {
345 let vols = FileManager.default.mountedVolumeURLs(
346 includingResourceValuesForKeys: nil, options: [.skipHiddenVolumes]) ?? []
347 return vols.contains { $0.path == "/Volumes/clover" }
348 && FileManager.default.fileExists(atPath: archiveRoot.path)
349 }
350
351 /// Ensure the zenith SMB share is mounted, attempting to mount it with saved
352 /// keychain credentials and polling briefly if it wasn't.
353 private func ensureZenithMounted() async -> Bool {
354 if zenithMounted() { return true }
355 await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
356 DispatchQueue.global(qos: .utility).async {
357 let p = Process()
358 p.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
359 p.arguments = ["-e", "mount volume \"smb://clo@zenith.local/clover\""]
360 try? p.run()
361 p.waitUntilExit()
362 cont.resume()
363 }
364 }
365 for _ in 0..<12 {
366 if zenithMounted() { return true }
367 try? await Task.sleep(nanoseconds: 500_000_000)
368 }
369 return zenithMounted()
370 }
371
372 private func stamp(_ format: String, _ date: Date = Date()) -> String {
373 let f = DateFormatter()
374 f.dateFormat = format
375 return f.string(from: date)
376 }
377}
378
379// MARK: - View
380
381struct ContentView: View {
382 @ObservedObject var controller: AppController
383
384 var body: some View {
385 VStack(alignment: .leading, spacing: 12) {
386 HStack {
387 Text("Clover Recorder").font(.headline)
388 Spacer()
389 if controller.isRecording {
390 Text(timeString(controller.elapsed))
391 .font(.system(.body, design: .monospaced)).foregroundStyle(.red)
392 }
393 }
394
395 if !controller.permissionOK {
396 Label("Screen Recording permission needed", systemImage: "exclamationmark.triangle")
397 .font(.caption).foregroundStyle(.orange)
398 }
399
400 Picker("", selection: $controller.destination) {
401 Text("Sessions").tag("Sessions")
402 Text("Journal").tag("Journal")
403 }
404 .pickerStyle(.segmented)
405 .disabled(controller.isRecording)
406
407 Divider()
408
409 VStack(alignment: .leading, spacing: 6) {
410 Text("Capture").font(.caption).foregroundStyle(.secondary)
411 ForEach(controller.displays, id: \.id) { d in
412 Toggle(
413 displayLabel(d),
414 isOn: Binding(
415 get: { controller.enabledDisplays.contains(d.id) },
416 set: { controller.setDisplay(d.id, on: $0) })
417 ).disabled(controller.isRecording)
418 }
419 Toggle("Desktop audio", isOn: $controller.includeDesktop).disabled(controller.isRecording)
420 if controller.hasMic {
421 Toggle("Microphone", isOn: $controller.includeMic).disabled(controller.isRecording)
422 } else {
423 Label("No microphone connected", systemImage: "exclamationmark.triangle.fill")
424 .foregroundStyle(.yellow)
425 }
426 Toggle("REAPER (MIDI)", isOn: $controller.reaperMidi).disabled(controller.isRecording)
427 }
428 .toggleStyle(.checkbox)
429
430 Spacer()
431
432 if let last = controller.lastSession, !controller.isRecording {
433 Button(action: { controller.copyReference() }) {
434 HStack(spacing: 6) {
435 Image(systemName: "doc.on.doc")
436 Text(last).font(.system(.caption, design: .monospaced))
437 }
438 }
439 .buttonStyle(.borderless)
440 .help("Copy session reference to clipboard")
441 }
442 if let status = controller.statusText {
443 Text(status).font(.caption).foregroundStyle(.secondary).lineLimit(1)
444 }
445 if let err = controller.errorMessage {
446 Text(err).font(.caption).foregroundStyle(.red).lineLimit(2)
447 }
448
449 VStack(spacing: 4) {
450 Button(action: { controller.toggle() }) {
451 HStack(spacing: 6) {
452 if !controller.isRecording && !controller.hasMic {
453 Image(systemName: "exclamationmark.triangle.fill")
454 }
455 Text(controller.isRecording ? "Stop" : "Start Recording")
456 }
457 .frame(maxWidth: .infinity)
458 }
459 .controlSize(.large)
460 .tint(controller.isRecording ? .red : (controller.hasMic ? .accentColor : .yellow))
461 .disabled(!controller.permissionOK && !controller.isRecording)
462
463 if !controller.isRecording && !controller.hasMic {
464 Text("Will record without microphone")
465 .font(.caption2).foregroundStyle(.yellow)
466 }
467 }
468
469 HStack {
470 Button("Quit") { NSApp.terminate(nil) }.font(.caption)
471 Spacer()
472 }
473 }
474 .padding(14)
475 .frame(width: 320)
476 }
477
478 private func displayLabel(_ d: DisplayInfo) -> String {
479 let idx = controller.displays.firstIndex(where: { $0.id == d.id }) ?? 0
480 let side = controller.displays.count == 2 ? (idx == 0 ? " (left)" : " (right)") : ""
481 return "Screen \(idx + 1)\(side) — \(d.width)×\(d.height)"
482 }
483
484 private func timeString(_ t: TimeInterval) -> String {
485 let s = Int(t)
486 return String(format: "%02d:%02d", s / 60, s % 60)
487 }
488}
src/Recorder/engine/Sources/recorder/Recorder.swift created+512
......@@ -0,0 +1,512 @@
1import AVFoundation
2import CoreGraphics
3import CoreMedia
4import Foundation
5import ScreenCaptureKit
6
7let recorderVersion = "0.1.0"
8
9// MARK: - Logging
10
11// Optional log file so diagnostics survive launch methods that discard
12// stdout/stderr (e.g. `open` / LaunchServices).
13var logFile: FileHandle?
14
15func openLogFile(_ path: String) {
16 FileManager.default.createFile(atPath: path, contents: nil)
17 logFile = FileHandle(forWritingAtPath: path)
18}
19
20private func emit(_ message: String) {
21 let line = "[recorder] " + message + "\n"
22 FileHandle.standardError.write(Data(line.utf8))
23 if let logFile {
24 logFile.write(Data(line.utf8))
25 }
26}
27
28func logErr(_ message: String) { emit(message) }
29func logInfo(_ message: String) { emit(message) }
30
31// MARK: - Host clock
32
33/// Seconds on the mach host clock — the same clock ScreenCaptureKit and
34/// AVCapture stamp their sample buffers with, so values are directly comparable
35/// across every stream.
36func hostSeconds() -> Double {
37 CMTimeGetSeconds(CMClockGetTime(CMClockGetHostTimeClock()))
38}
39
40// MARK: - Device discovery
41
42struct DisplayInfo: Codable {
43 let id: UInt32
44 let width: Int
45 let height: Int
46 let x: Int
47 let y: Int
48}
49
50struct DeviceInfo: Codable {
51 let uid: String
52 let name: String
53}
54
55struct DiscoveredDevices: Codable {
56 let displays: [DisplayInfo]
57 let cameras: [DeviceInfo]
58 let audioInputs: [DeviceInfo]
59}
60
61enum Devices {
62 static func discover() async throws -> DiscoveredDevices {
63 let content = try await SCShareableContent.excludingDesktopWindows(
64 false, onScreenWindowsOnly: false)
65
66 let displays =
67 content.displays
68 .sorted { $0.frame.origin.x < $1.frame.origin.x }
69 .map {
70 DisplayInfo(
71 id: $0.displayID,
72 width: Int($0.frame.width),
73 height: Int($0.frame.height),
74 x: Int($0.frame.origin.x),
75 y: Int($0.frame.origin.y))
76 }
77
78 let cameras = AVCaptureDevice.DiscoverySession(
79 deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera],
80 mediaType: .video, position: .unspecified
81 ).devices.map { DeviceInfo(uid: $0.uniqueID, name: $0.localizedName) }
82
83 let audioInputs = AVCaptureDevice.DiscoverySession(
84 deviceTypes: [.microphone, .external],
85 mediaType: .audio, position: .unspecified
86 ).devices.map { DeviceInfo(uid: $0.uniqueID, name: $0.localizedName) }
87
88 return DiscoveredDevices(displays: displays, cameras: cameras, audioInputs: audioInputs)
89 }
90
91 static func audioDevice(matching wanted: String) -> AVCaptureDevice? {
92 let devices = AVCaptureDevice.DiscoverySession(
93 deviceTypes: [.microphone, .external],
94 mediaType: .audio, position: .unspecified
95 ).devices
96 if wanted == "default" {
97 return AVCaptureDevice.default(for: .audio) ?? devices.first
98 }
99 return devices.first { $0.uniqueID == wanted || $0.localizedName == wanted }
100 }
101
102 static func videoDevice(matching wanted: String) -> AVCaptureDevice? {
103 let devices = AVCaptureDevice.DiscoverySession(
104 deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera],
105 mediaType: .video, position: .unspecified
106 ).devices
107 if wanted == "default" {
108 return AVCaptureDevice.default(for: .video) ?? devices.first
109 }
110 return devices.first { $0.uniqueID == wanted || $0.localizedName == wanted }
111 }
112}
113
114// MARK: - Manifest
115
116struct StreamManifest: Codable {
117 let name: String
118 let file: String
119 let kind: String
120 var displayID: UInt32?
121 var deviceUID: String?
122 var width: Int?
123 var height: Int?
124 var fps: Int?
125 let firstSampleHostSeconds: Double
126 let lastSampleHostSeconds: Double
127 let durationSeconds: Double
128 let frames: Int
129 let dropped: Int // real drops (input not ready / append failed)
130 let repeated: Int // CFR frames re-emitted to hold the rate on a static screen
131 var offsetSeconds: Double
132}
133
134struct SessionManifest: Codable {
135 let recorderVersion: String
136 let label: String
137 let createdEpoch: Double
138 let hostClockAtStart: Double
139 let tStartHostSeconds: Double
140 let streams: [StreamManifest]
141}
142
143// MARK: - Stream sink protocol
144
145/// Anything that records one stream to one file and reports where it sat on the
146/// shared host clock, so the engine can align and summarize them uniformly.
147protocol RecordingStream: AnyObject {
148 var firstPTS: Double { get }
149 func finish() async
150 func manifest() -> StreamManifest
151}
152
153// MARK: - Stream writer (passthrough: audio + camera)
154
155/// Owns one AVAssetWriter + input and turns a flow of CMSampleBuffers into one
156/// file, lazily starting the writer session on the first buffer and recording
157/// that buffer's host-clock timestamp for later alignment.
158final class StreamWriter: RecordingStream {
159 let name: String
160 let kind: String
161 let url: URL
162 var displayID: UInt32?
163 var deviceUID: String?
164 var width: Int?
165 var height: Int?
166 var fps: Int?
167
168 private let writer: AVAssetWriter
169 private let input: AVAssetWriterInput
170 private let lock = NSLock()
171
172 private(set) var firstPTS: Double = .nan
173 private(set) var lastPTS: Double = .nan
174 private(set) var frames = 0
175 private(set) var dropped = 0
176 private var started = false
177 private var failed = false
178
179 init(
180 url: URL, name: String, kind: String, fileType: AVFileType,
181 settings: [String: Any], mediaType: AVMediaType
182 ) throws {
183 self.url = url
184 self.name = name
185 self.kind = kind
186 self.writer = try AVAssetWriter(outputURL: url, fileType: fileType)
187 self.input = AVAssetWriterInput(mediaType: mediaType, outputSettings: settings)
188 self.input.expectsMediaDataInRealTime = true
189 if writer.canAdd(input) {
190 writer.add(input)
191 } else {
192 throw RecorderError("cannot add \(mediaType.rawValue) input for \(name)")
193 }
194 }
195
196 func append(_ sb: CMSampleBuffer) {
197 lock.lock()
198 defer { lock.unlock() }
199 if failed { return }
200
201 let pts = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sb))
202
203 if !started {
204 guard writer.startWriting() else {
205 failed = true
206 logErr("\(name): startWriting failed: \(writer.error?.localizedDescription ?? "?")")
207 return
208 }
209 writer.startSession(atSourceTime: CMSampleBufferGetPresentationTimeStamp(sb))
210 firstPTS = pts
211 started = true
212 }
213
214 if input.isReadyForMoreMediaData {
215 if input.append(sb) {
216 frames += 1
217 lastPTS = pts
218 } else {
219 dropped += 1
220 if writer.status == .failed {
221 failed = true
222 logErr("\(name): append failed: \(writer.error?.localizedDescription ?? "?")")
223 }
224 }
225 } else {
226 dropped += 1
227 }
228 }
229
230 func finish() async {
231 lock.lock()
232 let didStart = started
233 if didStart { input.markAsFinished() }
234 lock.unlock()
235
236 guard didStart else {
237 logErr("\(name): no samples captured, nothing written")
238 return
239 }
240 await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
241 writer.finishWriting { cont.resume() }
242 }
243 if writer.status == .failed {
244 logErr("\(name): finishWriting failed: \(writer.error?.localizedDescription ?? "?")")
245 }
246 }
247
248 func manifest() -> StreamManifest {
249 StreamManifest(
250 name: name,
251 file: url.lastPathComponent,
252 kind: kind,
253 displayID: displayID,
254 deviceUID: deviceUID,
255 width: width,
256 height: height,
257 fps: fps,
258 firstSampleHostSeconds: firstPTS,
259 lastSampleHostSeconds: lastPTS,
260 durationSeconds: (firstPTS.isNaN || lastPTS.isNaN) ? 0 : (lastPTS - firstPTS),
261 frames: frames,
262 dropped: dropped,
263 repeated: 0,
264 offsetSeconds: 0)
265 }
266}
267
268struct RecorderError: Error, CustomStringConvertible, LocalizedError {
269 let description: String
270 init(_ description: String) { self.description = description }
271 var errorDescription: String? { description }
272}
273
274// MARK: - Constant-frame-rate video writer (screens)
275
276/// ScreenCaptureKit only delivers a frame when the screen changes, so a raw
277/// passthrough yields a variable, sparse frame rate whose duration ends at the
278/// last change rather than at the stop time — which would desync against audio.
279///
280/// This writer decouples capture from encoding: SCOutput hands every fresh
281/// frame to `update(_:)`, and an independent timer emits the most-recent frame
282/// at the target rate, timestamped on the host clock. The result stays dense,
283/// its duration matches wall-clock, and it never drifts against the audio.
284final class CFRVideoWriter: RecordingStream {
285 let name: String
286 let kind = "screen"
287 let url: URL
288 var displayID: UInt32?
289 let width: Int
290 let height: Int
291 let fps: Int
292
293 private let writer: AVAssetWriter
294 private let input: AVAssetWriterInput
295 private let adaptor: AVAssetWriterInputPixelBufferAdaptor
296 private let queue = DispatchQueue(label: "clover.cfr")
297 private let lock = NSLock()
298
299 private var latest: CVPixelBuffer?
300 private var lastEmitted: CVPixelBuffer?
301 private var timer: DispatchSourceTimer?
302
303 private(set) var firstPTS = Double.nan
304 private var lastPTS = Double.nan
305 private(set) var frames = 0
306 private(set) var repeated = 0
307 private(set) var dropped = 0
308 private var started = false
309 private var failed = false
310
311 init(url: URL, name: String, width: Int, height: Int, fps: Int, bitrate: Int) throws {
312 self.url = url
313 self.name = name
314 self.width = width
315 self.height = height
316 self.fps = fps
317
318 let settings: [String: Any] = [
319 AVVideoCodecKey: AVVideoCodecType.hevc,
320 AVVideoWidthKey: width,
321 AVVideoHeightKey: height,
322 AVVideoCompressionPropertiesKey: [
323 AVVideoAverageBitRateKey: bitrate,
324 AVVideoExpectedSourceFrameRateKey: fps,
325 AVVideoMaxKeyFrameIntervalKey: fps * 2,
326 ],
327 ]
328 writer = try AVAssetWriter(outputURL: url, fileType: .mov)
329 input = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
330 input.expectsMediaDataInRealTime = true
331 adaptor = AVAssetWriterInputPixelBufferAdaptor(
332 assetWriterInput: input,
333 sourcePixelBufferAttributes: [
334 kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
335 kCVPixelBufferWidthKey as String: width,
336 kCVPixelBufferHeightKey as String: height,
337 ])
338 guard writer.canAdd(input) else { throw RecorderError("cannot add video input for \(name)") }
339 writer.add(input)
340 }
341
342 /// Latest frame from ScreenCaptureKit; retained until replaced (SCK won't
343 /// recycle a buffer we still hold, so no copy is needed).
344 func update(_ sb: CMSampleBuffer) {
345 guard let pb = CMSampleBufferGetImageBuffer(sb) else { return }
346 lock.lock()
347 latest = pb
348 lock.unlock()
349 }
350
351 func start() {
352 let interval = 1.0 / Double(fps)
353 let t = DispatchSource.makeTimerSource(queue: queue)
354 t.schedule(deadline: .now() + interval, repeating: interval, leeway: .milliseconds(2))
355 t.setEventHandler { [weak self] in self?.tick() }
356 timer = t
357 t.resume()
358 }
359
360 private func tick() {
361 lock.lock()
362 let pb = latest
363 lock.unlock()
364 guard let pb else { return } // no frame captured yet
365
366 let pts = hostSeconds()
367 if !started {
368 guard writer.startWriting() else {
369 failed = true
370 logErr("\(name): startWriting failed: \(writer.error?.localizedDescription ?? "?")")
371 return
372 }
373 writer.startSession(atSourceTime: CMTime(seconds: pts, preferredTimescale: 1_000_000))
374 firstPTS = pts
375 started = true
376 }
377 guard !failed else { return }
378 guard input.isReadyForMoreMediaData else {
379 dropped += 1
380 return
381 }
382 let time = CMTime(seconds: pts, preferredTimescale: 1_000_000)
383 if adaptor.append(pb, withPresentationTime: time) {
384 frames += 1
385 lastPTS = pts
386 if pb === lastEmitted { repeated += 1 }
387 lastEmitted = pb
388 } else {
389 dropped += 1
390 if writer.status == .failed {
391 failed = true
392 logErr("\(name): append failed: \(writer.error?.localizedDescription ?? "?")")
393 }
394 }
395 }
396
397 func finish() async {
398 timer?.cancel()
399 timer = nil
400 // Drain the timer queue so no tick races with finalization.
401 await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
402 queue.async { cont.resume() }
403 }
404 guard started else {
405 logErr("\(name): no frames captured, nothing written")
406 return
407 }
408 input.markAsFinished()
409 await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
410 writer.finishWriting { cont.resume() }
411 }
412 if writer.status == .failed {
413 logErr("\(name): finishWriting failed: \(writer.error?.localizedDescription ?? "?")")
414 }
415 }
416
417 func manifest() -> StreamManifest {
418 StreamManifest(
419 name: name,
420 file: url.lastPathComponent,
421 kind: kind,
422 displayID: displayID,
423 deviceUID: nil,
424 width: width,
425 height: height,
426 fps: fps,
427 firstSampleHostSeconds: firstPTS,
428 lastSampleHostSeconds: lastPTS,
429 durationSeconds: (firstPTS.isNaN || lastPTS.isNaN) ? 0 : (lastPTS - firstPTS),
430 frames: frames,
431 dropped: dropped,
432 repeated: repeated,
433 offsetSeconds: 0)
434 }
435}
436
437// MARK: - Sample delegates
438
439final class SCOutput: NSObject, SCStreamOutput, SCStreamDelegate {
440 let label: String
441 private let onScreen: (CMSampleBuffer) -> Void
442 private let onAudio: ((CMSampleBuffer) -> Void)?
443
444 private let counterLock = NSLock()
445 private(set) var screenSeen = 0
446 private(set) var screenComplete = 0
447 private(set) var audioSeen = 0
448
449 init(
450 label: String, onScreen: @escaping (CMSampleBuffer) -> Void,
451 onAudio: ((CMSampleBuffer) -> Void)?
452 ) {
453 self.label = label
454 self.onScreen = onScreen
455 self.onAudio = onAudio
456 }
457
458 func stream(
459 _ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer,
460 of type: SCStreamOutputType
461 ) {
462 switch type {
463 case .screen:
464 counterLock.lock()
465 screenSeen += 1
466 counterLock.unlock()
467 guard CMSampleBufferGetImageBuffer(sampleBuffer) != nil, Self.isComplete(sampleBuffer) else {
468 return
469 }
470 counterLock.lock()
471 screenComplete += 1
472 counterLock.unlock()
473 onScreen(sampleBuffer)
474 case .audio:
475 counterLock.lock()
476 audioSeen += 1
477 counterLock.unlock()
478 onAudio?(sampleBuffer)
479 default:
480 break
481 }
482 }
483
484 func stream(_ stream: SCStream, didStopWithError error: Error) {
485 logErr("SCStream stopped with error: \(error.localizedDescription)")
486 }
487
488 static func isComplete(_ sb: CMSampleBuffer) -> Bool {
489 guard
490 let arr = CMSampleBufferGetSampleAttachmentsArray(sb, createIfNecessary: false)
491 as? [[SCStreamFrameInfo: Any]],
492 let info = arr.first,
493 let raw = info[.status] as? Int,
494 let status = SCFrameStatus(rawValue: raw)
495 else { return false }
496 return status == .complete
497 }
498}
499
500final class AVOutput: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate,
501 AVCaptureAudioDataOutputSampleBufferDelegate
502{
503 private let cb: (CMSampleBuffer) -> Void
504 init(_ cb: @escaping (CMSampleBuffer) -> Void) { self.cb = cb }
505
506 func captureOutput(
507 _ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer,
508 from connection: AVCaptureConnection
509 ) {
510 cb(sampleBuffer)
511 }
512}
src/Recorder/engine/Sources/recorder/main.swift created+167
......@@ -0,0 +1,167 @@
1import AVFoundation
2import CoreGraphics
3import Dispatch
4import Foundation
5
6// Clover Recorder capture core.
7//
8// recorder list
9// recorder record --out DIR [options]
10//
11// record options:
12// --label NAME session label (default "session")
13// --screen ID capture this display (repeatable; default: all)
14// --system-audio capture the system-audio mix into desktop.m4a
15// --mic UID|default capture this audio input into mic.m4a
16// --camera UID|default capture this camera into cam.mov
17// --fps N frame rate (default 30)
18// --max-width N clamp the longest screen side, px (default 3840; 0 = native)
19// --bpp F HEVC bits per pixel-frame (default 0.05)
20// --duration S auto-stop after S seconds (otherwise runs until SIGINT/SIGTERM)
21
22func fail(_ message: String) -> Never {
23 logErr(message)
24 exit(1)
25}
26
27func runList() {
28 let sem = DispatchSemaphore(value: 0)
29 var exitCode: Int32 = 0
30 Task {
31 do {
32 let devices = try await Devices.discover()
33 let encoder = JSONEncoder()
34 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
35 let data = try encoder.encode(devices)
36 FileHandle.standardOutput.write(data)
37 FileHandle.standardOutput.write(Data("\n".utf8))
38 } catch {
39 logErr("list failed: \(error.localizedDescription)")
40 logErr("(Screen Recording permission may not be granted to this binary yet.)")
41 exitCode = 1
42 }
43 sem.signal()
44 }
45 sem.wait()
46 exit(exitCode)
47}
48
49func parseRecordConfig(_ args: [String]) -> RecordConfig {
50 var outDir: URL?
51 var label = "session"
52 var displayIDs: [CGDirectDisplayID] = []
53 var systemAudio = false
54 var micUID: String?
55 var cameraUID: String?
56 var fps = 30
57 var maxWidth = 3840
58 var bpp = 0.05
59 var duration: Double?
60 var logPath: String?
61
62 var i = 0
63 func next(_ flag: String) -> String {
64 i += 1
65 guard i < args.count else { fail("missing value for \(flag)") }
66 return args[i]
67 }
68
69 while i < args.count {
70 let arg = args[i]
71 switch arg {
72 case "--out": outDir = URL(fileURLWithPath: next(arg))
73 case "--label": label = next(arg)
74 case "--screen":
75 guard let id = UInt32(next(arg)) else { fail("--screen expects a numeric display id") }
76 displayIDs.append(id)
77 case "--system-audio": systemAudio = true
78 case "--mic": micUID = next(arg)
79 case "--camera": cameraUID = next(arg)
80 case "--fps": fps = Int(next(arg)) ?? 30
81 case "--max-width": maxWidth = Int(next(arg)) ?? 3840
82 case "--bpp": bpp = Double(next(arg)) ?? 0.05
83 case "--duration": duration = Double(next(arg))
84 case "--log": logPath = next(arg)
85 default: fail("unknown option: \(arg)")
86 }
87 i += 1
88 }
89
90 guard let outDir else { fail("--out DIR is required") }
91 return RecordConfig(
92 outDir: outDir, label: label, displayIDs: displayIDs, systemAudio: systemAudio,
93 micUID: micUID, cameraUID: cameraUID, fps: fps, maxWidth: maxWidth, bitsPerPixel: bpp,
94 duration: duration, logPath: logPath)
95}
96
97func runRecord(_ args: [String]) {
98 let cfg = parseRecordConfig(args)
99 if let logPath = cfg.logPath { openLogFile(logPath) }
100 logInfo("recorder \(recorderVersion) starting: \(cfg.outDir.path)")
101 let engine = CaptureEngine(cfg)
102
103 // Never block the main thread: ScreenCaptureKit delivers start/stop
104 // completions on the main queue, so we keep main free via dispatchMain()
105 // and drive everything from background queues / Tasks.
106 let stopGuard = StopOnce()
107 func triggerStop() {
108 guard stopGuard.begin() else { return }
109 logInfo("stopping…")
110 Task {
111 await engine.stop()
112 exit(0)
113 }
114 }
115
116 Task {
117 do {
118 try await engine.start()
119 } catch {
120 logErr("start failed: \(error.localizedDescription)")
121 exit(1)
122 }
123 }
124
125 // Stop on SIGINT / SIGTERM, or after --duration.
126 let sigQueue = DispatchQueue(label: "clover.signals")
127 var sources: [DispatchSourceSignal] = []
128 for sig in [SIGINT, SIGTERM] {
129 signal(sig, SIG_IGN)
130 let src = DispatchSource.makeSignalSource(signal: sig, queue: sigQueue)
131 src.setEventHandler { triggerStop() }
132 src.resume()
133 sources.append(src)
134 }
135 if let duration = cfg.duration {
136 sigQueue.asyncAfter(deadline: .now() + duration) { triggerStop() }
137 }
138 signalSources = sources // keep alive
139
140 dispatchMain()
141}
142
143/// One-shot guard so duration + signal can't both run finalize.
144final class StopOnce {
145 private let lock = NSLock()
146 private var started = false
147 func begin() -> Bool {
148 lock.lock()
149 defer { lock.unlock() }
150 if started { return false }
151 started = true
152 return true
153 }
154}
155
156var signalSources: [DispatchSourceSignal] = []
157
158// Entry point. With no args (or `menubar`) it runs as a menubar app; with
159// `record`/`list` it runs headless for SSH/scripting. Same signed binary either
160// way, so the one Screen Recording grant covers both.
161let argv = Array(CommandLine.arguments.dropFirst())
162switch argv.first {
163case "list": runList()
164case "record": runRecord(Array(argv.dropFirst()))
165case nil, "menubar": MainActor.assumeIsolated { runMenubar() }
166default: fail("unknown command: \(argv.first ?? "")")
167}
src/Recorder/setup-signing.sh created+82
......@@ -0,0 +1,82 @@
1#!/usr/bin/env bash
2# One-time: create a stable self-signed code-signing identity in a dedicated
3# keychain so the Screen Recording grant survives rebuilds.
4#
5# Why a dedicated keychain (not login): it can be created, unlocked, and
6# imported into entirely over SSH with a known password — no GUI, no touching
7# your login keychain. TCC keys the Screen Recording grant on the app's
8# *designated requirement* (bundle id + cert leaf), which stays identical across
9# rebuilds, so you grant once and never get re-prompted.
10#
11# The cert is self-signed and untrusted; that's fine — Gatekeeper is bypassed
12# for locally-built, non-quarantined apps, and TCC matching doesn't need trust.
13#
14# Safe to re-run; it's idempotent. The keychain password is local-only and has
15# nothing to do with your macOS login password.
16set -euo pipefail
17
18CN="Clover Code Signing"
19KC="$HOME/Library/Keychains/clover-signing.keychain-db"
20KCPW="${CLOVER_KEYCHAIN_PW:-clover}"
21P12="$HOME/.clover-code-signing.p12" # backup so the identity survives keychain loss
22
23ensure_searchlist() {
24 local existing
25 existing=$(security list-keychains -d user | sed -e 's/^ *//' -e 's/"//g')
26 case "$existing" in
27 *clover-signing*) ;;
28 *) security list-keychains -d user -s "$KC" $existing ;;
29 esac
30}
31
32if [[ -f "$KC" ]] && security find-identity -p codesigning "$KC" 2>/dev/null | grep -q "$CN"; then
33 security unlock-keychain -p "$KCPW" "$KC" 2>/dev/null || true
34 ensure_searchlist
35 echo "✅ '$CN' already present in $KC"
36 exit 0
37fi
38
39TMP="$(mktemp -d)"
40trap 'rm -rf "$TMP"' EXIT
41
42if [[ -f "$P12" ]]; then
43 echo "==> reusing saved identity from $P12 (keeps the same TCC requirement)"
44 cp "$P12" "$TMP/cs.p12"
45else
46 echo "==> generating new self-signed code-signing certificate"
47 cat > "$TMP/cs.conf" <<EOF
48[ req ]
49distinguished_name = dn
50x509_extensions = v3
51prompt = no
52[ dn ]
53CN = $CN
54[ v3 ]
55keyUsage = critical, digitalSignature
56extendedKeyUsage = critical, codeSigning
57basicConstraints = critical, CA:false
58EOF
59 openssl req -x509 -newkey rsa:2048 -keyout "$TMP/cs.key" -out "$TMP/cs.crt" \
60 -days 3650 -nodes -config "$TMP/cs.conf" >/dev/null 2>&1
61 openssl pkcs12 -export -inkey "$TMP/cs.key" -in "$TMP/cs.crt" -out "$TMP/cs.p12" \
62 -passout pass:clover -name "$CN" >/dev/null 2>&1
63 cp "$TMP/cs.p12" "$P12"
64 chmod 600 "$P12"
65fi
66
67echo "==> (re)creating dedicated keychain $KC"
68security delete-keychain "$KC" 2>/dev/null || true
69security create-keychain -p "$KCPW" "$KC"
70security set-keychain-settings "$KC" # no auto-lock timeout
71security unlock-keychain -p "$KCPW" "$KC"
72security import "$TMP/cs.p12" -k "$KC" -P clover -A -T /usr/bin/codesign
73security set-key-partition-list -S apple-tool:,apple:,unsigned: -s -k "$KCPW" "$KC" >/dev/null 2>&1 || true
74ensure_searchlist
75
76echo
77if security find-identity -p codesigning "$KC" | grep -q "$CN"; then
78 echo "✅ '$CN' ready in $KC. build.sh will sign with it automatically."
79else
80 echo "⚠️ identity not found after setup — check the output above."
81 exit 1
82fi