From d30077f479a5acd9b7a03f17738791a46cec87c1 Mon Sep 17 00:00:00 2001 From: MaxNet Digital Date: Fri, 6 Nov 2026 18:06:36 +0000 Subject: [PATCH 1/2] Add snapshots and screen recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A two-finger tap (right-click) while the hand is on takes a snapshot that includes the hand and the drawing, copies it to the clipboard and saves it to a folder. Two quick taps start a video; one more tap or Esc stops it. The toolbar gets camera and record buttons, the menu gets matching items, and ⌃⌥S works as a keyboard backup. Uses ScreenCaptureKit and leaves TinyHand's own toolbar and hints out of the picture. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SeC6rL1eFertMov9Bcnapr --- Sources/TinyHand/AppDelegate.swift | 104 ++++++-- Sources/TinyHand/Capture.swift | 302 +++++++++++++++++++++++ Sources/TinyHand/InputTap.swift | 20 ++ Sources/TinyHand/OverlayController.swift | 219 ++++++++++++++++ Sources/TinyHand/Preferences.swift | 38 ++- Sources/TinyHand/SettingsView.swift | 43 ++++ Sources/TinyHand/SettingsWindow.swift | 3 + Sources/TinyHand/Toolbar.swift | 36 +++ 8 files changed, 740 insertions(+), 25 deletions(-) create mode 100644 Sources/TinyHand/Capture.swift diff --git a/Sources/TinyHand/AppDelegate.swift b/Sources/TinyHand/AppDelegate.swift index aa6dd5f..4cce3db 100644 --- a/Sources/TinyHand/AppDelegate.swift +++ b/Sources/TinyHand/AppDelegate.swift @@ -13,8 +13,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var toggleItem: NSMenuItem? private var clearItem: NSMenuItem? private var soundItem: NSMenuItem? + private var snapItem: NSMenuItem? + private var recordItem: NSMenuItem? private var toggleHotKey: UInt32? private var clearHotKey: UInt32? + private var captureHotKey: UInt32? + + private enum ShortcutKind { + case toggle, clear, capture + } func applicationDidFinishLaunching(_ notification: Notification) { NSApp.mainMenu = editingMenu() @@ -27,6 +34,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { overlay.onSoundChanged = { [weak self] in self?.refreshMenu() } + overlay.onRecordingChanged = { [weak self] in + self?.refreshMenu() + } overlay.extraPassThrough = { [weak self] in guard let frame = self?.statusItem.button?.window?.frame else { return [] @@ -74,6 +84,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { menu.addItem(clear) menu.addItem(sound) menu.addItem(.separator()) + let snap = NSMenuItem(title: "", action: #selector(takeSnapshot), keyEquivalent: "") + let record = NSMenuItem(title: "", action: #selector(toggleRecording), keyEquivalent: "") + snapItem = snap + recordItem = record + menu.addItem(snap) + menu.addItem(record) + menu.addItem(.separator()) menu.addItem(NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: ",")) if Updater.isAvailable { menu.addItem(NSMenuItem(title: "Check for Updates…", action: #selector(checkForUpdates), keyEquivalent: "")) @@ -86,10 +103,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private func setupSettings() { settings.applyToggleShortcut = { [weak self] s in - self?.apply(s, isToggle: true) ?? false + self?.apply(s, kind: .toggle) ?? false } settings.applyClearShortcut = { [weak self] s in - self?.apply(s, isToggle: false) ?? false + self?.apply(s, kind: .clear) ?? false + } + settings.applyCaptureShortcut = { [weak self] s in + self?.apply(s, kind: .capture) ?? false } settings.updater = Updater.isAvailable ? updater : nil settings.onRecording = { [weak self] recording in @@ -127,56 +147,89 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { toggleItem?.title = "\(on ? "Turn Pointer Off" : "Turn Pointer On") \(Preferences.toggleShortcut.display)" clearItem?.title = "Clear Drawing \(Preferences.clearShortcut.display)" soundItem?.state = overlay.soundOn ? .on : .off + snapItem?.title = "Take Snapshot \(Preferences.captureShortcut.display)" + recordItem?.title = overlay.isRecording ? "Stop Recording" : "Record Video \(Preferences.captureShortcut.display) twice" permissionItem?.isHidden = AXIsProcessTrusted() } - private func apply(_ s: Shortcut, isToggle: Bool) -> Bool { - unregisterHotKeys() - let old = isToggle ? Preferences.toggleShortcut : Preferences.clearShortcut - if isToggle { - Preferences.toggleShortcut = s - } else { - Preferences.clearShortcut = s + private func shortcut(_ kind: ShortcutKind) -> Shortcut { + switch kind { + case .toggle: return Preferences.toggleShortcut + case .clear: return Preferences.clearShortcut + case .capture: return Preferences.captureShortcut } - let ok = registerHotKeys() - if ok { - return true + } + + private func setShortcut(_ s: Shortcut, _ kind: ShortcutKind) { + switch kind { + case .toggle: Preferences.toggleShortcut = s + case .clear: Preferences.clearShortcut = s + case .capture: Preferences.captureShortcut = s } + } + + private func hotKey(_ kind: ShortcutKind) -> UInt32? { + switch kind { + case .toggle: return toggleHotKey + case .clear: return clearHotKey + case .capture: return captureHotKey + } + } + + private func apply(_ s: Shortcut, kind: ShortcutKind) -> Bool { unregisterHotKeys() - if isToggle { - Preferences.toggleShortcut = old - } else { - Preferences.clearShortcut = old + let old = shortcut(kind) + setShortcut(s, kind) + registerHotKeys() + if hotKey(kind) != nil { + return true } + unregisterHotKeys() + setShortcut(old, kind) registerHotKeys() return false } @discardableResult private func registerHotKeys() -> Bool { - guard toggleHotKey == nil, clearHotKey == nil else { + guard toggleHotKey == nil, clearHotKey == nil, captureHotKey == nil else { return true } let t = Preferences.toggleShortcut let c = Preferences.clearShortcut + let k = Preferences.captureShortcut toggleHotKey = HotKeyCenter.shared.register(keyCode: t.keyCode, modifiers: t.modifiers) { [weak self] in self?.overlay.toggle() } clearHotKey = HotKeyCenter.shared.register(keyCode: c.keyCode, modifiers: c.modifiers) { [weak self] in self?.overlay.clearInk() } - return toggleHotKey != nil && clearHotKey != nil + captureHotKey = HotKeyCenter.shared.register(keyCode: k.keyCode, modifiers: k.modifiers) { [weak self] in + self?.overlay.captureGesture(hidePointer: false) + } + return toggleHotKey != nil && clearHotKey != nil && captureHotKey != nil } private func unregisterHotKeys() { - if let id = toggleHotKey { - HotKeyCenter.shared.unregister(id) - } - if let id = clearHotKey { + for id in [toggleHotKey, clearHotKey, captureHotKey].compactMap({ $0 }) { HotKeyCenter.shared.unregister(id) } toggleHotKey = nil clearHotKey = nil + captureHotKey = nil + } + + @objc private func takeSnapshot() { + // Give the menu a moment to close so it isn't in the picture. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.overlay.takeSnapshot(hidePointer: false) + } + } + + @objc private func toggleRecording() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.overlay.toggleRecording() + } } @objc private func toggle() { @@ -207,8 +260,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } @objc private func quit() { - overlay.deactivate() - NSApp.terminate(nil) + // Let a recording finish writing its file before quitting. + overlay.stopRecording { + self.overlay.deactivate() + NSApp.terminate(nil) + } } private func enableLaunchAtLoginOnFirstRun() { diff --git a/Sources/TinyHand/Capture.swift b/Sources/TinyHand/Capture.swift new file mode 100644 index 0000000..3e994ee --- /dev/null +++ b/Sources/TinyHand/Capture.swift @@ -0,0 +1,302 @@ +import AppKit +import AVFoundation +import CoreImage +import ScreenCaptureKit + +enum CaptureError: Error { + case permission + case noDisplay + case failed(String) +} + +/// Snapshots and screen recordings that include the hand and the ink, but leave out +/// TinyHand's own toolbar, hints and other windows. +final class Capture { + private var recorder: Recorder? + var isRecording: Bool { recorder != nil } + + /// Called on the main queue when the system stops a recording by itself + /// (for example, the display went away or permission was taken back). + var onStoppedBySystem: (() -> Void)? + + static let shutter = NSSound( + contentsOfFile: "/System/Library/Components/CoreAudio.component/Contents/SharedSupport/SystemSounds/system/Screen Capture.aif", + byReference: true) + + private static let askedKey = "screenRecordingAsked" + + /// True when TinyHand may capture the screen. The first time it isn't, macOS shows its own prompt + /// and this returns false with `shouldExplain` false; after that, the caller should explain. + static func checkPermission() -> (granted: Bool, shouldExplain: Bool) { + if CGPreflightScreenCaptureAccess() { + return (true, false) + } + let asked = UserDefaults.standard.bool(forKey: askedKey) + UserDefaults.standard.set(true, forKey: askedKey) + CGRequestScreenCaptureAccess() + return (false, asked) + } + + static var hasPermission: Bool { CGPreflightScreenCaptureAccess() } + + // MARK: Snapshot + + func snapshot(screen: NSScreen, keep: [Int]) async throws -> CGImage { + let (filter, display) = try await Capture.filter(for: screen, keeping: keep) + let config = Capture.configuration(display: display, scale: screen.backingScaleFactor, maxPixels: nil) + if #available(macOS 14.0, *) { + return try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) + } + return try await OneFrame().grab(filter: filter, config: config) + } + + // MARK: Recording + + func startRecording(screen: NSScreen, keep: [Int], to url: URL) async throws { + guard recorder == nil else { + return + } + let (filter, display) = try await Capture.filter(for: screen, keeping: keep) + // H.264 tops out around 4K, so big Retina screens are scaled down to fit. + let config = Capture.configuration(display: display, scale: screen.backingScaleFactor, maxPixels: 3840 * 2160) + config.minimumFrameInterval = CMTime(value: 1, timescale: 60) + config.queueDepth = 6 + config.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange + let r = try Recorder(url: url, filter: filter, config: config) + r.onStoppedBySystem = { [weak self] in + self?.onStoppedBySystem?() + } + try await r.start() + recorder = r + } + + /// Finishes the video file and returns where it was saved, or nil if nothing was recorded. + func stopRecording() async -> URL? { + guard let r = recorder else { + return nil + } + recorder = nil + return await r.stop() + } + + // MARK: Files + + static func newFileURL(ext: String) -> URL { + let folder = Preferences.captureFolder + try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd 'at' HH.mm.ss" + let name = "TinyHand \(f.string(from: Date()))" + var url = folder.appendingPathComponent("\(name).\(ext)") + var n = 2 + while FileManager.default.fileExists(atPath: url.path) { + url = folder.appendingPathComponent("\(name) \(n).\(ext)") + n += 1 + } + return url + } + + static var folderName: String { + FileManager.default.displayName(atPath: Preferences.captureFolder.path) + } + + // MARK: Helpers + + private static func filter(for screen: NSScreen, keeping keep: [Int]) async throws -> (SCContentFilter, SCDisplay) { + let content: SCShareableContent + do { + content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false) + } catch { + Log.write("screen capture content failed: \(error.localizedDescription)") + throw CaptureError.permission + } + let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID + guard let display = content.displays.first(where: { $0.displayID == number }) ?? content.displays.first else { + throw CaptureError.noDisplay + } + let me = getpid() + let keepIDs = Set(keep.map { CGWindowID($0) }) + let hide = content.windows.filter { $0.owningApplication?.processID == me && !keepIDs.contains($0.windowID) } + return (SCContentFilter(display: display, excludingWindows: hide), display) + } + + private static func configuration(display: SCDisplay, scale: CGFloat, maxPixels: CGFloat?) -> SCStreamConfiguration { + var w = CGFloat(display.width) * scale + var h = CGFloat(display.height) * scale + if let maxPixels = maxPixels, w * h > maxPixels { + let k = (maxPixels / (w * h)).squareRoot() + w *= k + h *= k + } + let config = SCStreamConfiguration() + config.width = max(2, Int(w) / 2 * 2) + config.height = max(2, Int(h) / 2 * 2) + config.showsCursor = false + config.pixelFormat = kCVPixelFormatType_32BGRA + return config + } + + static func isComplete(_ sample: CMSampleBuffer) -> Bool { + guard CMSampleBufferIsValid(sample), + let attachments = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: false) + as? [[SCStreamFrameInfo: Any]], + let raw = attachments.first?[.status] as? Int, + let status = SCFrameStatus(rawValue: raw) else { + return false + } + return status == .complete + } +} + +/// macOS 13 has no SCScreenshotManager, so take the first frame of a short stream instead. +private final class OneFrame: NSObject, SCStreamOutput { + private let queue = DispatchQueue(label: "tinyhand.capture.frame") + private var continuation: CheckedContinuation? + + func grab(filter: SCContentFilter, config: SCStreamConfiguration) async throws -> CGImage { + let stream = SCStream(filter: filter, configuration: config, delegate: nil) + try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue) + let image: CGImage = try await withCheckedThrowingContinuation { c in + queue.sync { continuation = c } + stream.startCapture { [weak self] error in + if let error = error { + self?.finish(.failure(error)) + } + } + } + try? await stream.stopCapture() + return image + } + + private func finish(_ result: Result) { + queue.async { + let c = self.continuation + self.continuation = nil + c?.resume(with: result) + } + } + + func stream(_ stream: SCStream, didOutputSampleBuffer sample: CMSampleBuffer, of type: SCStreamOutputType) { + guard type == .screen, continuation != nil, Capture.isComplete(sample), + let pixels = CMSampleBufferGetImageBuffer(sample) else { + return + } + let ci = CIImage(cvPixelBuffer: pixels) + guard let image = CIContext().createCGImage(ci, from: ci.extent) else { + return + } + let c = continuation + continuation = nil + c?.resume(returning: image) + } +} + +/// Writes ScreenCaptureKit frames into a .mov file. +private final class Recorder: NSObject, SCStreamOutput, SCStreamDelegate { + let url: URL + var onStoppedBySystem: (() -> Void)? + + private let filter: SCContentFilter + private let config: SCStreamConfiguration + private let writer: AVAssetWriter + private let input: AVAssetWriterInput + private var stream: SCStream? + private let queue = DispatchQueue(label: "tinyhand.capture.record") + private var started = false + private var finishing = false + private var firstTime = CMTime.invalid + private var firstClock: CFTimeInterval = 0 + + init(url: URL, filter: SCContentFilter, config: SCStreamConfiguration) throws { + self.url = url + self.filter = filter + self.config = config + writer = try AVAssetWriter(outputURL: url, fileType: .mov) + let pixels = config.width * config.height + input = AVAssetWriterInput(mediaType: .video, outputSettings: [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: config.width, + AVVideoHeightKey: config.height, + AVVideoCompressionPropertiesKey: [ + AVVideoAverageBitRateKey: max(6_000_000, pixels * 2), + AVVideoExpectedSourceFrameRateKey: 60, + AVVideoMaxKeyFrameIntervalKey: 120, + AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel, + ] as [String: Any], + ]) + input.expectsMediaDataInRealTime = true + guard writer.canAdd(input) else { + throw CaptureError.failed("can't add a video track") + } + writer.add(input) + super.init() + } + + func start() async throws { + guard writer.startWriting() else { + throw writer.error ?? CaptureError.failed("couldn't start the video file") + } + let s = SCStream(filter: filter, configuration: config, delegate: self) + try s.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue) + stream = s + do { + try await s.startCapture() + } catch { + writer.cancelWriting() + try? FileManager.default.removeItem(at: url) + throw error + } + Log.write("recording started \(config.width)x\(config.height) -> \(url.path)") + } + + func stream(_ stream: SCStream, didOutputSampleBuffer sample: CMSampleBuffer, of type: SCStreamOutputType) { + guard type == .screen, !finishing, Capture.isComplete(sample) else { + return + } + let time = CMSampleBufferGetPresentationTimeStamp(sample) + if !started { + writer.startSession(atSourceTime: time) + firstTime = time + firstClock = CACurrentMediaTime() + started = true + } + if input.isReadyForMoreMediaData { + input.append(sample) + } + } + + func stream(_ stream: SCStream, didStopWithError error: Error) { + Log.write("recording stopped by the system: \(error.localizedDescription)") + DispatchQueue.main.async { [weak self] in + self?.onStoppedBySystem?() + } + } + + func stop() async -> URL? { + try? await stream?.stopCapture() + stream = nil + return await withCheckedContinuation { (c: CheckedContinuation) in + queue.async { + self.finishing = true + guard self.started, self.writer.status == .writing else { + self.writer.cancelWriting() + try? FileManager.default.removeItem(at: self.url) + Log.write("recording had no frames; nothing saved") + c.resume(returning: nil) + return + } + self.input.markAsFinished() + // Frames only arrive when something changes, so end the video at the moment + // Stop was pressed rather than at the last frame. + let elapsed = CMTime(seconds: CACurrentMediaTime() - self.firstClock, preferredTimescale: 600) + self.writer.endSession(atSourceTime: CMTimeAdd(self.firstTime, elapsed)) + self.writer.finishWriting { + let ok = self.writer.status == .completed + Log.write(ok ? "recording saved \(self.url.path)" + : "recording failed: \(self.writer.error?.localizedDescription ?? "?")") + c.resume(returning: ok ? self.url : nil) + } + } + } + } +} diff --git a/Sources/TinyHand/InputTap.swift b/Sources/TinyHand/InputTap.swift index 8114e4b..ecee3c3 100644 --- a/Sources/TinyHand/InputTap.swift +++ b/Sources/TinyHand/InputTap.swift @@ -7,12 +7,20 @@ final class InputTap { var onMouseUp: (() -> Void)? var onLeftDragged: (() -> Void)? var onPassThroughPress: ((CGPoint) -> Void)? + /// Right-click (a two-finger tap on a trackpad) while the pointer is on. Takes a snapshot. + var onSecondaryClick: (() -> Void)? private let lock = NSLock() private var _active = false private var _passThroughRects: [CGRect] = [] private var _interactKey: CGEventFlags = [] private var _interactPress = false + private var _secondaryCaptures = true + + var secondaryCaptures: Bool { + get { lock.withLock { _secondaryCaptures } } + set { lock.withLock { _secondaryCaptures = newValue } } + } var passThroughRects: [CGRect] { get { lock.withLock { _passThroughRects } } @@ -175,6 +183,18 @@ final class InputTap { return Unmanaged.passUnretained(event) } + if secondaryCaptures { + switch type { + case .rightMouseDown: + DispatchQueue.main.async { [weak self] in self?.onSecondaryClick?() } + return nil + case .rightMouseUp, .rightMouseDragged: + return nil + default: + break + } + } + switch type { case .leftMouseDown, .rightMouseDown, .otherMouseDown: DispatchQueue.main.async { [weak self] in self?.onMouseDown?() } diff --git a/Sources/TinyHand/OverlayController.swift b/Sources/TinyHand/OverlayController.swift index 4a60e95..93ad83f 100644 --- a/Sources/TinyHand/OverlayController.swift +++ b/Sources/TinyHand/OverlayController.swift @@ -21,6 +21,10 @@ final class OverlayController { private var dragEvents = 0 private let toolbar = Toolbar() + private let capture = Capture() + private var pendingSnap: DispatchWorkItem? + private var captureBusy = false + private static let doubleTapWindow: TimeInterval = 0.35 private var revealUntil = Date.distantPast private var normalCursor = false private var ownMenuOpen = false @@ -44,6 +48,8 @@ final class OverlayController { private(set) var isActive = false private var activity: NSObjectProtocol? var onActiveChanged: (() -> Void)? + var onRecordingChanged: (() -> Void)? + var isRecording: Bool { capture.isRecording } var onNeedsPermission: (() -> Void)? init() { @@ -95,6 +101,13 @@ final class OverlayController { CursorHider.reassert(urgent: true) self?.noteButtonEvent() } + inputTap.onSecondaryClick = { [weak self] in + self?.captureGesture(hidePointer: false) + } + inputTap.secondaryCaptures = Preferences.secondaryClickCaptures + capture.onStoppedBySystem = { [weak self] in + self?.stopRecording() + } inputTap.onPassThroughPress = { [weak self] location in guard let screen = self?.currentScreen() else { return @@ -112,6 +125,12 @@ final class OverlayController { toolbar.onToggleSound = { [weak self] in self?.soundOn.toggle() } + toolbar.onSnap = { [weak self] in + self?.takeSnapshot(hidePointer: true) + } + toolbar.onRecord = { [weak self] in + self?.toggleRecording() + } toolbar.onExit = { [weak self] in self?.exit() } @@ -125,6 +144,7 @@ final class OverlayController { NotificationCenter.default.addObserver(forName: Preferences.changed, object: nil, queue: .main) { [weak self] _ in self?.syncSound() self?.inputTap.interactKey = Preferences.interactKey.cgFlags + self?.inputTap.secondaryCaptures = Preferences.secondaryClickCaptures self?.armView.updateInkColor() self?.tapSound.load(Preferences.tapSound) if let self = self, self.armView.style != PointerStyle.saved { @@ -298,6 +318,7 @@ final class OverlayController { return } isActive = false + stopRecording() timer?.invalidate() timer = nil @@ -427,6 +448,204 @@ final class OverlayController { return CGRect(x: r.minX, y: primaryH - r.maxY, width: r.width, height: r.height) } + // MARK: Snapshots and videos + + /// One press takes a snapshot; two quick presses start a video; any press while recording stops it. + func captureGesture(hidePointer: Bool) { + if capture.isRecording { + stopRecording() + return + } + if let pending = pendingSnap { + pending.cancel() + pendingSnap = nil + toggleRecording() + return + } + let work = DispatchWorkItem { [weak self] in + self?.pendingSnap = nil + self?.takeSnapshot(hidePointer: hidePointer) + } + pendingSnap = work + DispatchQueue.main.asyncAfter(deadline: .now() + OverlayController.doubleTapWindow, execute: work) + } + + func takeSnapshot(hidePointer: Bool) { + guard !captureBusy, let screen = currentScreen() else { + return + } + guard screenPermissionOK() else { + return + } + captureBusy = true + // From the toolbar the hand would just be pointing at the (hidden) toolbar, so leave it out. + let hide = hidePointer && isActive && !armView.pointerHidden + if hide { + armView.pointerHidden = true + } + let keep = [window.windowNumber] + DispatchQueue.main.asyncAfter(deadline: .now() + (hide ? 0.12 : 0)) { [weak self] in + Task { @MainActor in + guard let self = self else { + return + } + defer { + if hide && !self.normalCursor { + self.armView.pointerHidden = false + } + self.captureBusy = false + } + do { + let image = try await self.capture.snapshot(screen: screen, keep: keep) + Capture.shutter?.stop() + Capture.shutter?.play() + self.flash(self.deliver(image), key: "Snapshot") + } catch { + self.captureFailed(error) + } + } + } + } + + func toggleRecording() { + if capture.isRecording { + stopRecording() + return + } + guard !captureBusy, let screen = currentScreen() else { + return + } + guard screenPermissionOK() else { + return + } + captureBusy = true + // Shown before recording starts, so it is left out of the video like the toolbar. + flash(Preferences.secondaryClickCaptures ? "two-finger tap or Esc to stop" : "press \(Preferences.captureShortcut.display) or Esc to stop", + key: "● Recording") + let url = Capture.newFileURL(ext: "mov") + let keep = [window.windowNumber] + Task { @MainActor [weak self] in + guard let self = self else { + return + } + do { + try await self.capture.startRecording(screen: screen, keep: keep, to: url) + self.toolbar.setRecording(true) + self.onRecordingChanged?() + } catch { + self.toolbar.hideHint() + self.captureFailed(error) + } + self.captureBusy = false + } + } + + func stopRecording(then done: (() -> Void)? = nil) { + guard capture.isRecording else { + done?() + return + } + toolbar.setRecording(false) + Task { @MainActor [weak self] in + guard let self = self else { + return + } + let url = await self.capture.stopRecording() + self.onRecordingChanged?() + defer { + done?() + } + guard let url = url else { + self.flash("couldn't save the video", key: "Recording") + return + } + if Preferences.captureCopies { + NSPasteboard.general.clearContents() + NSPasteboard.general.writeObjects([url as NSURL]) + } + Capture.shutter?.play() + if self.isActive { + let copied = Preferences.captureCopies ? "copied · " : "" + self.flash("\(copied)saved to \(Capture.folderName)", key: "Video") + } else { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + } + } + + /// Copies and/or saves the picture and returns a short message about what happened. + private func deliver(_ image: CGImage) -> String { + let rep = NSBitmapImageRep(cgImage: image) + guard let png = rep.representation(using: .png, properties: [:]) else { + return "couldn't make the picture" + } + var done: [String] = [] + if Preferences.captureCopies { + let pb = NSPasteboard.general + pb.clearContents() + pb.setData(png, forType: .png) + done.append("copied") + } + if Preferences.captureSaves || !Preferences.captureCopies { + let url = Capture.newFileURL(ext: "png") + do { + try png.write(to: url) + done.append("saved to \(Capture.folderName)") + Log.write("snapshot saved \(url.path)") + } catch { + done.append("couldn't save to \(Capture.folderName)") + Log.write("snapshot save failed: \(error.localizedDescription)") + } + } + return done.joined(separator: " · ") + } + + private func flash(_ text: String, key: String) { + // A new hint window during a recording would show up in the video. + guard isActive, !capture.isRecording else { + return + } + toolbar.showHint([(key: key, text: text)]) + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in + self?.toolbar.hideHint() + } + } + + private func screenPermissionOK() -> Bool { + let check = Capture.checkPermission() + if check.granted { + return true + } + exit() + if check.shouldExplain { + explainScreenPermission() + } + return false + } + + private func captureFailed(_ error: Error) { + Log.write("capture failed: \(error)") + if let e = error as? CaptureError, case .permission = e { + exit() + explainScreenPermission() + return + } + flash("something went wrong", key: "Capture") + } + + private func explainScreenPermission() { + let alert = NSAlert() + alert.messageText = "Let TinyHand take snapshots and videos" + alert.informativeText = "Turn on TinyHand in System Settings → Privacy & Security → Screen & System Audio Recording. Then quit TinyHand and open it again." + alert.addButton(withTitle: "Open System Settings") + alert.addButton(withTitle: "Not Now") + NSApp.activate(ignoringOtherApps: true) + if alert.runModal() == .alertFirstButtonReturn, + let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { + NSWorkspace.shared.open(url) + } + } + func runClickTest() { func time(_ label: String, _ body: () -> Void) { let t = CFAbsoluteTimeGetCurrent() diff --git a/Sources/TinyHand/Preferences.swift b/Sources/TinyHand/Preferences.swift index 5c04cdc..b1f9c83 100644 --- a/Sources/TinyHand/Preferences.swift +++ b/Sources/TinyHand/Preferences.swift @@ -9,6 +9,7 @@ struct Shortcut: Equatable { static let toggleDefault = Shortcut(keyCode: UInt32(kVK_ANSI_H), modifiers: UInt32(controlKey | optionKey), display: "⌃⌥H") static let clearDefault = Shortcut(keyCode: UInt32(kVK_ANSI_X), modifiers: UInt32(controlKey | optionKey), display: "⌃⌥X") + static let captureDefault = Shortcut(keyCode: UInt32(kVK_ANSI_S), modifiers: UInt32(controlKey | optionKey), display: "⌃⌥S") } enum InteractKey: String, CaseIterable { @@ -92,6 +93,40 @@ enum Preferences { set { saveShortcut(newValue, "shortcut.clear") } } + static var captureShortcut: Shortcut { + get { loadShortcut("shortcut.capture") ?? .captureDefault } + set { saveShortcut(newValue, "shortcut.capture") } + } + + /// Right-click / two-finger tap takes a snapshot (twice quickly records a video). + static var secondaryClickCaptures: Bool { + get { defaults.object(forKey: "capture.secondaryClick") as? Bool ?? true } + set { set(newValue, "capture.secondaryClick") } + } + + static var captureCopies: Bool { + get { defaults.object(forKey: "capture.copy") as? Bool ?? true } + set { set(newValue, "capture.copy") } + } + + static var captureSaves: Bool { + get { defaults.object(forKey: "capture.save") as? Bool ?? true } + set { set(newValue, "capture.save") } + } + + static var defaultCaptureFolder: URL { + FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Desktop") + } + + static var captureFolder: URL { + get { + defaults.string(forKey: "capture.folder").map { URL(fileURLWithPath: $0, isDirectory: true) } + ?? defaultCaptureFolder + } + set { set(newValue.path, "capture.folder") } + } + static let pointerSizeRange: ClosedRange = 0.08...0.20 static var pointerSize: CGFloat { @@ -177,7 +212,8 @@ enum Preferences { static func resetAll() { for key in ["shortcut.toggle", "shortcut.clear", "pointerSize", "showShadow", "fadeSeconds", "sound.hands", "sound.laser", "interactKey", "interactKeepsHand", "pinnedStyles", "pointerStyle", - "inkChoice", "showInkButton", "tapSound"] { + "inkChoice", "showInkButton", "tapSound", "shortcut.capture", + "capture.secondaryClick", "capture.copy", "capture.save", "capture.folder"] { defaults.removeObject(forKey: key) } NotificationCenter.default.post(name: changed, object: nil) diff --git a/Sources/TinyHand/SettingsView.swift b/Sources/TinyHand/SettingsView.swift index f2b0ddf..a549601 100644 --- a/Sources/TinyHand/SettingsView.swift +++ b/Sources/TinyHand/SettingsView.swift @@ -48,6 +48,7 @@ final class SettingsModel: ObservableObject { var applyToggleShortcut: ((Shortcut) -> Bool)? var applyClearShortcut: ((Shortcut) -> Bool)? + var applyCaptureShortcut: ((Shortcut) -> Bool)? var onRecording: ((Bool) -> Void)? var updater: Updater? @Published var shortcutError: String? @@ -250,6 +251,7 @@ final class SettingsModel: ObservableObject { Preferences.resetAll() _ = applyToggleShortcut?(.toggleDefault) _ = applyClearShortcut?(.clearDefault) + _ = applyCaptureShortcut?(.captureDefault) shortcutError = nil pinMessage = nil revision += 1 @@ -419,10 +421,51 @@ private struct GeneralPage: View { Row("Clear the drawing") { ShortcutField(shortcut: Preferences.clearShortcut, model: model) { model.applyClearShortcut?($0) ?? false } } + Row("Snapshot", detail: "Press twice quickly to record a video.") { + ShortcutField(shortcut: Preferences.captureShortcut, model: model) { model.applyCaptureShortcut?($0) ?? false } + } } if let error = model.shortcutError { Text(error).font(.system(size: 12)).foregroundColor(.red) } + Card(title: "Snapshots and videos") { + if !CGPreflightScreenCaptureAccess() { + Row("Screen Recording", detail: "Needed for snapshots and videos. Reopen TinyHand after allowing it.") { + PillButton(title: "Open Settings") { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { + NSWorkspace.shared.open(url) + } + } + } + } + Row("Two-finger tap takes a snapshot", + detail: "Or right-click. Tap twice quickly to record a video, once more to stop.") { + Toggle("", isOn: model.binding({ Preferences.secondaryClickCaptures }, { Preferences.secondaryClickCaptures = $0 })) + .toggleStyle(.switch).labelsHidden().focusable(false) + } + Row("Copy to the clipboard", detail: "Ready to paste into a chat or email.") { + Toggle("", isOn: model.binding({ Preferences.captureCopies }, { Preferences.captureCopies = $0 })) + .toggleStyle(.switch).labelsHidden().focusable(false) + } + Row("Save to a folder", detail: "Videos are always saved.") { + Toggle("", isOn: model.binding({ Preferences.captureSaves }, { Preferences.captureSaves = $0 })) + .toggleStyle(.switch).labelsHidden().focusable(false) + } + Row("Folder", detail: Preferences.captureFolder.path.replacingOccurrences(of: NSHomeDirectory(), with: "~")) { + PillButton(title: "Choose…") { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = true + panel.allowsMultipleSelection = false + panel.directoryURL = Preferences.captureFolder + panel.prompt = "Choose" + if panel.runModal() == .OK, let url = panel.url { + Preferences.captureFolder = url + } + } + } + } Card(title: "Clicking for real") { Row("Hold to use the app", detail: "Clicks, drags and scrolls reach the app.") { Picker("", selection: model.binding({ Preferences.interactKey }, { Preferences.interactKey = $0 })) { diff --git a/Sources/TinyHand/SettingsWindow.swift b/Sources/TinyHand/SettingsWindow.swift index 801406a..8e1f4de 100644 --- a/Sources/TinyHand/SettingsWindow.swift +++ b/Sources/TinyHand/SettingsWindow.swift @@ -9,6 +9,9 @@ final class SettingsWindow: NSObject, NSWindowDelegate { var applyClearShortcut: ((Shortcut) -> Bool)? { didSet { model.applyClearShortcut = applyClearShortcut } } + var applyCaptureShortcut: ((Shortcut) -> Bool)? { + didSet { model.applyCaptureShortcut = applyCaptureShortcut } + } var onRecording: ((Bool) -> Void)? { didSet { model.onRecording = onRecording } } diff --git a/Sources/TinyHand/Toolbar.swift b/Sources/TinyHand/Toolbar.swift index 95086a0..6f4257f 100644 --- a/Sources/TinyHand/Toolbar.swift +++ b/Sources/TinyHand/Toolbar.swift @@ -4,6 +4,8 @@ final class Toolbar { var onPick: ((PointerStyle) -> Void)? var onToggleSound: (() -> Void)? var onExit: (() -> Void)? + var onSnap: (() -> Void)? + var onRecord: (() -> Void)? var onFixPermission: (() -> Void)? private final class Panel: NSPanel { @@ -25,6 +27,9 @@ final class Toolbar { private var inkOpen = false private let permissionButton = Button() private var soundButton: Button? + private var snapButton: Button? + private var recordButton: Button? + private var recording = false private(set) var revealed = false private let tray: Panel @@ -92,6 +97,17 @@ final class Toolbar { } inkButton = ink stack.addArrangedSubview(ink) + let snap = makeButton(symbol: "camera.fill", tip: "Snapshot") { [weak self] in + self?.onSnap?() + } + snapButton = snap + stack.addArrangedSubview(snap) + let record = makeButton(symbol: "record.circle", tip: "Record video") { [weak self] in + self?.onRecord?() + } + recordButton = record + stack.addArrangedSubview(record) + refreshCaptureTips() let sound = makeButton(symbol: "speaker.wave.2.fill", tip: "Tap sound") { [weak self] in self?.onToggleSound?() } @@ -127,9 +143,29 @@ final class Toolbar { NotificationCenter.default.addObserver(forName: Preferences.changed, object: nil, queue: .main) { [weak self] _ in self?.rebuildStyles() self?.refreshInk() + self?.refreshCaptureTips() } } + func setRecording(_ on: Bool) { + recording = on + let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .medium) + let tip = on ? "Stop recording" : "Record video" + recordButton?.image = NSImage(systemSymbolName: on ? "stop.circle.fill" : "record.circle", + accessibilityDescription: tip)?.withSymbolConfiguration(config) + recordButton?.contentTintColor = on ? .systemRed : NSColor(white: 1, alpha: 0.85) + refreshCaptureTips() + } + + private func refreshCaptureTips() { + let gesture = Preferences.secondaryClickCaptures ? "two-finger tap" : Preferences.captureShortcut.display + let copy = Preferences.captureCopies ? "copy" : "" + let save = Preferences.captureSaves || !Preferences.captureCopies ? "save" : "" + let what = [copy, save].filter { !$0.isEmpty }.joined(separator: " & ") + snapButton?.toolTip = "Snapshot — \(what) (\(gesture))" + recordButton?.toolTip = recording ? "Stop recording (\(gesture) or Esc)" : "Record video (\(gesture) twice)" + } + private func toggleInk() { setInkOpen(!inkOpen) } From 7a1ec170d1f587ce60d819516164692bf24e495a Mon Sep 17 00:00:00 2001 From: MaxNet Digital Date: Fri, 6 Nov 2026 18:06:36 +0000 Subject: [PATCH 2/2] Reach Settings without the menu bar icon Opening TinyHand again shows Settings, and the toolbar gets a gear button, for Macs where the menu bar icon is hidden behind the notch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SeC6rL1eFertMov9Bcnapr --- Sources/TinyHand/AppDelegate.swift | 10 ++++++++++ Sources/TinyHand/OverlayController.swift | 4 ++++ Sources/TinyHand/Toolbar.swift | 4 ++++ 3 files changed, 18 insertions(+) diff --git a/Sources/TinyHand/AppDelegate.swift b/Sources/TinyHand/AppDelegate.swift index 4cce3db..99c9593 100644 --- a/Sources/TinyHand/AppDelegate.swift +++ b/Sources/TinyHand/AppDelegate.swift @@ -37,6 +37,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { overlay.onRecordingChanged = { [weak self] in self?.refreshMenu() } + overlay.onOpenSettings = { [weak self] in + self?.openSettings() + } overlay.extraPassThrough = { [weak self] in guard let frame = self?.statusItem.button?.window?.frame else { return [] @@ -60,6 +63,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } } + /// Opening TinyHand again (Finder, Spotlight, Launchpad) shows Settings, so it can be + /// reached even when the menu bar icon is hidden behind the notch or a crowded menu bar. + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + openSettings() + return false + } + func applicationWillTerminate(_ notification: Notification) { overlay.deactivate() } diff --git a/Sources/TinyHand/OverlayController.swift b/Sources/TinyHand/OverlayController.swift index 93ad83f..0fde852 100644 --- a/Sources/TinyHand/OverlayController.swift +++ b/Sources/TinyHand/OverlayController.swift @@ -49,6 +49,7 @@ final class OverlayController { private var activity: NSObjectProtocol? var onActiveChanged: (() -> Void)? var onRecordingChanged: (() -> Void)? + var onOpenSettings: (() -> Void)? var isRecording: Bool { capture.isRecording } var onNeedsPermission: (() -> Void)? @@ -131,6 +132,9 @@ final class OverlayController { toolbar.onRecord = { [weak self] in self?.toggleRecording() } + toolbar.onSettings = { [weak self] in + self?.onOpenSettings?() + } toolbar.onExit = { [weak self] in self?.exit() } diff --git a/Sources/TinyHand/Toolbar.swift b/Sources/TinyHand/Toolbar.swift index 6f4257f..4741de6 100644 --- a/Sources/TinyHand/Toolbar.swift +++ b/Sources/TinyHand/Toolbar.swift @@ -6,6 +6,7 @@ final class Toolbar { var onExit: (() -> Void)? var onSnap: (() -> Void)? var onRecord: (() -> Void)? + var onSettings: (() -> Void)? var onFixPermission: (() -> Void)? private final class Panel: NSPanel { @@ -113,6 +114,9 @@ final class Toolbar { } soundButton = sound stack.addArrangedSubview(sound) + stack.addArrangedSubview(makeButton(symbol: "gearshape.fill", tip: "Settings") { [weak self] in + self?.onSettings?() + }) stack.addArrangedSubview(makeButton(symbol: "xmark", tip: "Exit (Esc)") { [weak self] in self?.onExit?() })