Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@ jobs:
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Test input routing and image export
run: swift test
- name: Build (universal)
run: swift build -c release --arch arm64 --arch x86_64
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ let package = Package(
dependencies: [.product(name: "Sparkle", package: "Sparkle")],
path: "Sources/TinyHand",
linkerSettings: [.unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@executable_path/../Frameworks"])]
)
),
.testTarget(name: "TinyHandTests", dependencies: ["TinyHand"])
]
)
52 changes: 49 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,68 @@ macOS 13 or later, Apple Silicon and Intel.

## Install

Open `TinyHand.dmg`, drag TinyHand to Applications and allow **Accessibility** when asked. It needs that to turn your clicks into taps. TinyHand updates itself.
Open `TinyHand.dmg`, drag TinyHand to Applications and allow **Accessibility** when asked. It needs that to animate clicks and draw. Screenshot mode also asks for **Screen Recording** permission when you first save a capture.

This fork adds drawing export, native pointer interaction, and freeform screenshots.
The download links above still refer to the upstream release, not these fork features.

## Use

| | |
|---|---|
| Click the menu bar icon, or **⌃⌥H** | Turn the pointer on or off |
| Right-click the menu bar icon | Menu and Settings |
| Click | Tap (nothing underneath gets clicked) |
| Hold and drag | Draw on the screen |
| **Pointer** mode (default) | Click, double-click, right-click, and drag in macOS normally |
| **Draw** mode | Hold and drag to draw without clicking anything underneath |
| **Screenshot** mode | Drag a freeform shape, release, then click **Save** |
| Save button / **Save Drawing as PNG…** in the menu | Export your strokes to a transparent PNG |
| Hold **⌥ Option** in Draw or Screenshot mode | Temporarily interact with the app underneath (configurable in Settings) |
| **⌃⌥X** | Clear the drawing |
| **Esc** | Clear everything and turn the pointer off |

While the pointer is on, a small toolbar at the bottom of the screen lets you pick one of
36 hands or a laser dot, mute the tap sound, or exit.

### Save drawings and screenshots

In **Draw** mode, the save button exports all strokes since the last clear, including
lines that have faded. The PNG is cropped to the artwork with a transparent background;
the desktop and pointer are not included. Strokes on multiple displays retain their
desktop-relative positions. Clear, Esc, and turning the pointer off discard the drawing.

In **Screenshot** mode, draw around the content you want. The outline closes
automatically; click the save button or **Save Selected Area as PNG…** in the menu.
TinyHand captures the current screen contents inside the shape, cropped to its bounds,
with transparency outside it. The pointer, selection outline, toolbar, and drawing
overlays are excluded. A selection stays on the display where it started; draw again to
replace it, or clear it to select on another display.

Both actions open a standard save dialog so you choose the filename and destination.
Cancelling leaves your drawing or selection available to save again. If macOS denies
screen access, allow TinyHand in **System Settings → Privacy & Security → Screen
Recording**, then quit and reopen it if prompted.

### Development

```sh
swift build
swift test
swift run TinyHand --e2e-test
```

The E2E check needs Accessibility permission and a logged-in macOS desktop.
To also verify real PNG saves and record the flow over a privacy-safe native fixture,
grant Screen Recording permission and run `python3 scripts/capture-evidence.py dist/evidence`.
The output directory must not already exist. The fixture covers the primary display
during the recording and is closed when the check finishes.

If SwiftPM reports `safe.bareRepository is 'explicit'`, scope the workaround to the
build command rather than changing your global Git settings:

```sh
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.bareRepository GIT_CONFIG_VALUE_0=all swift test
```

## License

MIT. See [LICENSE](LICENSE).
36 changes: 36 additions & 0 deletions Sources/TinyHand/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
private var toggleItem: NSMenuItem?
private var clearItem: NSMenuItem?
private var soundItem: NSMenuItem?
private var saveDrawingItem: NSMenuItem?
private var saveScreenshotItem: NSMenuItem?
private var modeItems: [NSMenuItem] = []
private var toggleHotKey: UInt32?
private var clearHotKey: UInt32?

Expand All @@ -27,6 +30,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
overlay.onSoundChanged = { [weak self] in
self?.refreshMenu()
}
overlay.onCaptureChanged = { [weak self] in self?.refreshMenu() }
overlay.extraPassThrough = { [weak self] in
guard let frame = self?.statusItem.button?.window?.frame else {
return []
Expand Down Expand Up @@ -71,6 +75,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
clearItem = clear
soundItem = sound
menu.addItem(toggle)
menu.autoenablesItems = false
for (index, mode) in PointerMode.allCases.enumerated() {
let item = NSMenuItem(title: "\(mode.title) Mode", action: #selector(selectMode(_:)), keyEquivalent: "")
item.tag = index
item.image = NSImage(systemSymbolName: mode.symbol, accessibilityDescription: mode.title)
modeItems.append(item)
menu.addItem(item)
}
let saveDrawing = NSMenuItem(title: "Save Drawing as PNG…", action: #selector(saveDrawing), keyEquivalent: "")
let saveScreenshot = NSMenuItem(title: "Save Selected Area as PNG…", action: #selector(saveScreenshot), keyEquivalent: "")
saveDrawingItem = saveDrawing
saveScreenshotItem = saveScreenshot
menu.addItem(saveDrawing)
menu.addItem(saveScreenshot)
menu.addItem(clear)
menu.addItem(sound)
menu.addItem(.separator())
Expand Down Expand Up @@ -127,6 +145,11 @@ 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
saveDrawingItem?.isEnabled = overlay.canSaveDrawing
saveScreenshotItem?.isEnabled = overlay.canSaveScreenshot
for item in modeItems {
item.state = PointerMode.allCases[item.tag] == overlay.mode ? .on : .off
}
permissionItem?.isHidden = AXIsProcessTrusted()
}

Expand Down Expand Up @@ -187,6 +210,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
overlay.clearInk()
}

@objc private func selectMode(_ sender: NSMenuItem) {
overlay.setMode(PointerMode.allCases[sender.tag])
if !overlay.isActive { overlay.activate() }
}

@objc private func saveDrawing() {
overlay.saveDrawing()
}

@objc private func saveScreenshot() {
overlay.saveScreenshot()
}

@objc private func toggleSound() {
overlay.soundOn.toggle()
}
Expand Down
129 changes: 129 additions & 0 deletions Sources/TinyHand/Capture.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import AppKit
import ScreenCaptureKit

enum CaptureError: LocalizedError {
case emptyDrawing, emptySelection, imageCreation, displayUnavailable, permission, captureFailed(String)

var errorDescription: String? {
switch self {
case .emptyDrawing: return "Draw something first, then choose Save Drawing."
case .emptySelection: return "Draw a shape with an area at least 4 points wide and tall."
case .imageCreation: return "The PNG could not be created. Try a smaller drawing or selection."
case .displayUnavailable: return "The selected display is no longer available."
case .permission: return "Allow TinyHand in System Settings > Privacy & Security > Screen Recording, then try again. macOS may require you to quit and reopen TinyHand."
case .captureFailed(let detail): return "Screen capture failed: \(detail)"
}
}
}

enum Capture {
static func drawing(_ strokes: [Ink.Stroke], scale: CGFloat) throws -> CGImage {
guard !strokes.isEmpty else { throw CaptureError.emptyDrawing }
let bounds = strokes.reduce(CGRect.null) {
$0.union($1.path.boundingBoxOfPath.insetBy(dx: -$1.width / 2 - 2, dy: -$1.width / 2 - 2))
}.integral
let context = try bitmap(bounds: bounds, scale: scale)
context.setLineCap(.round)
context.setLineJoin(.round)
for stroke in strokes {
context.setStrokeColor(stroke.color)
context.setLineWidth(stroke.width)
context.addPath(stroke.path)
context.strokePath()
}
guard let image = context.makeImage() else { throw CaptureError.imageCreation }
return image
}

static func selection(_ points: [CGPoint]) throws -> CGPath {
guard points.count >= 3, let first = points.first else { throw CaptureError.emptySelection }
let path = CGMutablePath()
path.addLines(between: points)
path.closeSubpath()
let bounds = path.boundingBoxOfPath
let hasArea = zip(points.dropFirst(), points.dropFirst(2)).contains { pair in
let a = pair.0, b = pair.1
let left: CGFloat = (a.x - first.x) * (b.y - first.y)
let right: CGFloat = (a.y - first.y) * (b.x - first.x)
return abs(left - right) >= 4
}
guard bounds.width >= 4, bounds.height >= 4, hasArea else { throw CaptureError.emptySelection }
return path
}

static func screenshot(_ image: CGImage, path: CGPath, screenSize: CGSize) throws -> CGImage {
let screen = CGRect(origin: .zero, size: screenSize)
let bounds = path.boundingBoxOfPath.integral.intersection(screen)
guard !bounds.isNull, bounds.width >= 4, bounds.height >= 4 else { throw CaptureError.emptySelection }
let context = try bitmap(bounds: bounds, scale: CGFloat(image.width) / screenSize.width)
context.addPath(path)
context.clip(using: .evenOdd)
context.draw(image, in: screen)
guard let result = context.makeImage() else { throw CaptureError.imageCreation }
return result
}

private static func bitmap(bounds: CGRect, scale: CGFloat) throws -> CGContext {
let width = ceil(bounds.width * scale), height = ceil(bounds.height * scale)
guard scale.isFinite, scale > 0, width.isFinite, height.isFinite,
width > 0, height > 0, width * height <= 100_000_000,
let context = CGContext(data: nil, width: Int(width), height: Int(height),
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else {
throw CaptureError.imageCreation
}
context.scaleBy(x: scale, y: scale)
context.translateBy(x: -bounds.minX, y: -bounds.minY)
return context
}

static func png(_ image: CGImage) throws -> Data {
guard let data = NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:]) else {
throw CaptureError.imageCreation
}
return data
}

static func screen(id: CGDirectDisplayID, frame: CGRect) async throws -> CGImage {
if #available(macOS 14.0, *) {
let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
guard let display = content.displays.first(where: { $0.displayID == id }) else {
throw CaptureError.displayUnavailable
}
let apps = content.applications.filter { $0.processID == getpid() }
let filter = SCContentFilter(display: display, excludingApplications: apps, exceptingWindows: [])
let config = SCStreamConfiguration()
config.width = Int(CGFloat(display.width) * CGFloat(filter.pointPixelScale))
config.height = Int(CGFloat(display.height) * CGFloat(filter.pointPixelScale))
config.showsCursor = false
return try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config)
}
// macOS 13 predates SCScreenshotManager. The system tool also omits the cursor.
return try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("tinyhand-\(UUID().uuidString).png")
defer {
if FileManager.default.fileExists(atPath: url.path) {
do { try FileManager.default.removeItem(at: url) }
catch { Log.write("temporary screenshot cleanup failed: \(error.localizedDescription)") }
}
}
do {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
process.arguments = ["-x", "-R\(Int(frame.minX)),\(Int(frame.minY)),\(Int(frame.width)),\(Int(frame.height))", url.path]
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0,
let image = NSBitmapImageRep(data: try Data(contentsOf: url))?.cgImage else {
throw CaptureError.captureFailed("screencapture exited with status \(process.terminationStatus)")
}
continuation.resume(returning: image)
} catch {
continuation.resume(throwing: error)
}
}
}
}
}
Loading