Compare commits

7 Commits
Author SHA1 Message Date
marcoandClaude Opus 4.6 188b952be7 Fix mirrored image export with CGBitmapContext renderer
Draw base CGImage without coordinate flip, apply flip+scale only for
annotations. Save via direct NSBitmapImageRep access instead of
tiffRepresentation to avoid transform issues. Also redirect clipboard
images to saveFolder instead of temp directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-30 18:52:49 +02:00
marcoandClaude Opus 4.6 8d6c2213ad Fix arrows not drawing when dragged left or upward
CGRect.maxX/maxY standardize to positive values, collapsing
the arrow endpoint onto the start point for negative width/height.
Use origin + size instead to preserve direction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-22 07:39:56 +02:00
marcoandClaude Opus 4.6 519706dd41 Fix annotation position shift in editor and export
Use flipped container with Auto Layout for correct coordinate
mapping. Use lockFocusFlipped(true) in renderer so exported
annotations match canvas positions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-22 07:21:05 +02:00
marcoandClaude Opus 4.6 0fce3e6d28 Add Gatekeeper workaround to installation instructions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-02 09:22:52 +02:00
marcoandClaude Opus 4.6 4267650739 Add launch at login option, bump to v1.0.2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-02 09:08:34 +02:00
marcoandClaude Opus 4.6 052663b356 Remove Logitech Options+ setup section from docs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-01 22:58:14 +02:00
marcoandClaude Opus 4.6 827af0b239 Add README with documentation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-01 22:54:56 +02:00
9 changed files with 220 additions and 50 deletions
+119
View File
@@ -0,0 +1,119 @@
# SnipMC
**macOS Menu Bar Screenshot Tool mit integriertem Bildeditor**
![macOS](https://img.shields.io/badge/macOS-14.0+-blue)
![Swift](https://img.shields.io/badge/Swift-5-orange)
![License](https://img.shields.io/badge/Lizenz-Freeware-green)
SnipMC ist ein schlankes Screenshot-Tool für macOS, das in der Menüleiste lebt. Es bietet drei Aufnahme-Modi, einen integrierten Bildeditor für Annotationen und ein URL-Schema zur Steuerung über externe Tools wie Logitech Options+, Kurzbefehle oder Stream Deck.
## Features
- **Screenshot-Modi**: Ganzer Bildschirm, Fenster oder frei wählbarer Bereich
- **Bildeditor**: Pfeile, Rechtecke, Ellipsen und Text-Annotationen
- **Flexibler Zugriff**: Letzte Aufnahme bearbeiten, Bild aus Zwischenablage, Datei öffnen
- **URL-Schema** (`snipmc://`): Externe Steuerung per Tastenkürzel, Stream Deck etc.
- **Einstellungen**: Speicherort, Bildformat (PNG/JPG), konfigurierbare Tastenkürzel
- **Menüleisten-App**: Kein Dock-Icon, läuft dezent im Hintergrund
## Installation
1. `SnipMC.dmg` aus dem [aktuellen Release](https://github.com/mwende/SnipMC/releases/latest) herunterladen
2. DMG öffnen und `SnipMC.app` nach `/Programme` ziehen
3. Gatekeeper-Sperre aufheben (da die App nicht mit einem Apple Developer Zertifikat signiert ist):
```bash
xattr -cr /Applications/SnipMC.app
```
4. App starten — das Kamera-Icon erscheint in der Menüleiste
5. Beim ersten Start die Berechtigung **Bildschirmaufnahme** erteilen:
*Systemeinstellungen → Datenschutz & Sicherheit → Bildschirmaufnahme*
## Screenshot-Modi
| Modus | Beschreibung |
|---|---|
| Ganzer Bildschirm | Nimmt den gesamten Bildschirm auf |
| Fenster | Klicke auf ein Fenster, um es aufzunehmen |
| Bereich auswählen | Ziehe einen Rahmen um den gewünschten Bereich |
## Bildeditor
Der integrierte Editor öffnet sich nicht automatisch nach jeder Aufnahme, sondern nur auf expliziten Wunsch — über das Menü, per URL-Schema oder über die Tastenkombination.
### Werkzeuge
| Werkzeug | Beschreibung |
|---|---|
| Pfeil | Linie mit Pfeilspitze zeichnen |
| Rechteck | Rahmen um einen Bereich zeichnen |
| Ellipse | Kreis oder Oval zeichnen |
| Text | Klicke auf eine Stelle und tippe Text ein |
### Tastenkürzel im Editor
| Kürzel | Aktion |
|---|---|
| ⌘Z | Rückgängig |
| ⌘⇧Z | Wiederholen |
| ⌫ / Entf | Ausgewählte Annotation löschen |
| ⌘S | Speichern |
## URL-Schema
SnipMC registriert das URL-Schema `snipmc://` und kann so von externen Tools gesteuert werden.
| URL | Aktion |
|---|---|
| `snipmc://fullscreen` | Vollbild-Screenshot |
| `snipmc://window` | Fenster-Screenshot |
| `snipmc://region` | Bereich-Screenshot |
| `snipmc://region?edit=true` | Bereich aufnehmen + Editor öffnen |
| `snipmc://fullscreen?edit=true` | Vollbild aufnehmen + Editor öffnen |
| `snipmc://edit` | Bild aus Datei im Editor öffnen |
| `snipmc://editlast` | Letzten Screenshot im Editor bearbeiten |
| `snipmc://clipboard` | Bild aus Zwischenablage bearbeiten |
### Einrichtung per macOS Kurzbefehle
1. Kurzbefehle-App öffnen
2. Neuer Kurzbefehl → Aktion **URL öffnen**
3. URL eintragen, z. B.: `snipmc://region`
4. Optional: Tastaturkurzbefehl zuweisen
### Einrichtung per Terminal
```bash
open snipmc://region
open "snipmc://region?edit=true"
open snipmc://editlast
```
## Einstellungen
- **Ausgabe**: Nur speichern, nur Zwischenablage, oder beides
- **Bildformat**: PNG oder JPG
- **Speicherort**: Standard `~/Bilder/Screenshots`, frei wählbar
- **Tastenkürzel**: Für jeden Aufnahme-Modus individuell konfigurierbar
## Systemvoraussetzungen
- macOS 14.0 (Sonoma) oder neuer
- Berechtigung **Bildschirmaufnahme** erforderlich
## Entwicklung
```bash
# Voraussetzung: Xcode + XcodeGen
brew install xcodegen
# Projekt generieren und bauen
xcodegen generate
xcodebuild -project SnipMC.xcodeproj -scheme SnipMC -configuration Release build
```
## Autor
**Marco Wende — [Wende.IT](https://wende.it)**
Freeware — kostenlos nutzbar.
+4 -4
View File
@@ -274,7 +274,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "-"; CODE_SIGN_IDENTITY = "-";
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 3;
ENABLE_HARDENED_RUNTIME = NO; ENABLE_HARDENED_RUNTIME = NO;
INFOPLIST_FILE = Generated/Info.plist; INFOPLIST_FILE = Generated/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@@ -282,7 +282,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0.1; MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = it.wende.snipmc; PRODUCT_BUNDLE_IDENTIFIER = it.wende.snipmc;
PRODUCT_NAME = SnipMC; PRODUCT_NAME = SnipMC;
SDKROOT = macosx; SDKROOT = macosx;
@@ -352,7 +352,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "-"; CODE_SIGN_IDENTITY = "-";
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 3;
ENABLE_HARDENED_RUNTIME = NO; ENABLE_HARDENED_RUNTIME = NO;
INFOPLIST_FILE = Generated/Info.plist; INFOPLIST_FILE = Generated/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@@ -360,7 +360,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0.1; MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = it.wende.snipmc; PRODUCT_BUNDLE_IDENTIFIER = it.wende.snipmc;
PRODUCT_NAME = SnipMC; PRODUCT_NAME = SnipMC;
SDKROOT = macosx; SDKROOT = macosx;
-12
View File
@@ -122,18 +122,6 @@ struct HelpTab: View {
urlRow("snipmc://clipboard", "Zwischenablage bearbeiten") urlRow("snipmc://clipboard", "Zwischenablage bearbeiten")
} }
helpSection("Einrichtung in Logitech Options+") {
VStack(alignment: .leading, spacing: 4) {
Text("1. Logi Options+ \u{00f6}ffnen und Ger\u{00e4}t w\u{00e4}hlen")
Text("2. Taste zuweisen: Aktion = URL \u{00f6}ffnen")
Text("3. URL eintragen, z.\u{202f}B.:")
Text(" snipmc://region?edit=true")
.font(.system(.body, design: .monospaced))
.foregroundStyle(.secondary)
}
.font(.callout)
}
helpSection("Hinweise") { helpSection("Hinweise") {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("\u{2022} Berechtigung Bildschirmaufnahme erforderlich (Systemeinstellungen \u{2192} Datenschutz & Sicherheit)") Text("\u{2022} Berechtigung Bildschirmaufnahme erforderlich (Systemeinstellungen \u{2192} Datenschutz & Sicherheit)")
+23 -6
View File
@@ -242,24 +242,41 @@ final class AppCoordinator: ObservableObject {
private func openEditor(image: NSImage, sourceURL: URL) { private func openEditor(image: NSImage, sourceURL: URL) {
let controller = EditorWindowController(image: image, sourceURL: sourceURL) { [weak self] annotatedImage in let controller = EditorWindowController(image: image, sourceURL: sourceURL) { [weak self] annotatedImage in
guard let self, let annotatedImage else { return } guard let self, let annotatedImage else { return }
self.saveAnnotatedImage(annotatedImage, replacingFileAt: sourceURL) let targetURL: URL
if sourceURL.path.hasPrefix(NSTemporaryDirectory()) {
try? FileManager.default.createDirectory(at: saveFolder, withIntermediateDirectories: true)
targetURL = saveFolder.appendingPathComponent(Self.filename(ext: imageFormat.fileExtension))
} else {
targetURL = sourceURL
}
self.saveAnnotatedImage(annotatedImage, to: targetURL)
} }
editorController = controller editorController = controller
controller.showEditor() controller.showEditor()
} }
private func saveAnnotatedImage(_ image: NSImage, replacingFileAt url: URL) { private func saveAnnotatedImage(_ image: NSImage, to url: URL) {
guard let tiffData = image.tiffRepresentation, let bitmap: NSBitmapImageRep
let bitmap = NSBitmapImageRep(data: tiffData) else { return } if let rep = image.representations.first as? NSBitmapImageRep {
bitmap = rep
} else if let cgRef = image.cgImage(forProposedRect: nil, context: nil, hints: nil) {
bitmap = NSBitmapImageRep(cgImage: cgRef)
} else {
NSLog("SnipMC: could not create bitmap for saving")
return
}
let fileType: NSBitmapImageRep.FileType = imageFormat == .jpg ? .jpeg : .png let fileType: NSBitmapImageRep.FileType = imageFormat == .jpg ? .jpeg : .png
let properties: [NSBitmapImageRep.PropertyKey: Any] = fileType == .jpeg let properties: [NSBitmapImageRep.PropertyKey: Any] = fileType == .jpeg
? [.compressionFactor: 0.9] : [:] ? [.compressionFactor: 0.9] : [:]
guard let data = bitmap.representation(using: fileType, properties: properties) else { return } guard let data = bitmap.representation(using: fileType, properties: properties) else {
NSLog("SnipMC: could not encode image as \(imageFormat.rawValue)")
return
}
do { do {
try data.write(to: url, options: .atomic) try data.write(to: url, options: .atomic)
NSLog("SnipMC: saved to \(url.path)")
} catch { } catch {
NSLog("SnipMC: failed to save annotated image: \(error)") NSLog("SnipMC: failed to save annotated image: \(error)")
} }
+2 -1
View File
@@ -108,7 +108,8 @@ final class AnnotationCanvasView: NSView {
let handleRect: CGRect let handleRect: CGRect
if item.kind == .arrow { if item.kind == .arrow {
let start = item.rect.origin let start = item.rect.origin
let end = CGPoint(x: item.rect.maxX, y: item.rect.maxY) let end = CGPoint(x: item.rect.origin.x + item.rect.size.width,
y: item.rect.origin.y + item.rect.size.height)
handleRect = CGRect(x: min(start.x, end.x), y: min(start.y, end.y), handleRect = CGRect(x: min(start.x, end.x), y: min(start.y, end.y),
width: abs(end.x - start.x), height: abs(end.y - start.y)) width: abs(end.x - start.x), height: abs(end.y - start.y))
} else if item.kind == .text { } else if item.kind == .text {
+33 -10
View File
@@ -26,19 +26,39 @@ enum AnnotationRenderer {
} }
static func render(annotations: [AnnotationItem], onto image: NSImage) -> NSImage { static func render(annotations: [AnnotationItem], onto image: NSImage) -> NSImage {
guard let srcCG = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return image }
let w = srcCG.width
let h = srcCG.height
let size = image.size let size = image.size
let result = NSImage(size: size)
result.lockFocus()
image.draw(in: NSRect(origin: .zero, size: size)) guard let cs = srcCG.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB),
let ctx = CGContext(
data: nil, width: w, height: h,
bitsPerComponent: 8, bytesPerRow: 0, space: cs,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return image }
if let context = NSGraphicsContext.current?.cgContext { ctx.draw(srcCG, in: CGRect(x: 0, y: 0, width: w, height: h))
for item in annotations {
draw(item, in: context) ctx.translateBy(x: 0, y: CGFloat(h))
} let sx = CGFloat(w) / size.width
let sy = CGFloat(h) / size.height
ctx.scaleBy(x: sx, y: -sy)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: true)
for item in annotations {
draw(item, in: ctx)
} }
result.unlockFocus() NSGraphicsContext.restoreGraphicsState()
guard let resultCG = ctx.makeImage() else { return image }
let bitmapRep = NSBitmapImageRep(cgImage: resultCG)
bitmapRep.size = size
let result = NSImage(size: size)
result.addRepresentation(bitmapRep)
return result return result
} }
@@ -46,7 +66,8 @@ enum AnnotationRenderer {
private static func drawArrow(_ item: AnnotationItem, in context: CGContext) { private static func drawArrow(_ item: AnnotationItem, in context: CGContext) {
let start = item.rect.origin let start = item.rect.origin
let end = CGPoint(x: item.rect.maxX, y: item.rect.maxY) let end = CGPoint(x: item.rect.origin.x + item.rect.size.width,
y: item.rect.origin.y + item.rect.size.height)
context.move(to: start) context.move(to: start)
context.addLine(to: end) context.addLine(to: end)
@@ -96,9 +117,11 @@ enum AnnotationRenderer {
static func hitTest(point: CGPoint, item: AnnotationItem, tolerance: CGFloat = 6) -> Bool { static func hitTest(point: CGPoint, item: AnnotationItem, tolerance: CGFloat = 6) -> Bool {
switch item.kind { switch item.kind {
case .arrow: case .arrow:
let arrowEnd = CGPoint(x: item.rect.origin.x + item.rect.size.width,
y: item.rect.origin.y + item.rect.size.height)
return distanceToLine(point: point, return distanceToLine(point: point,
from: item.rect.origin, from: item.rect.origin,
to: CGPoint(x: item.rect.maxX, y: item.rect.maxY)) < tolerance + item.lineWidth to: arrowEnd) < tolerance + item.lineWidth
case .rectangle: case .rectangle:
let outer = item.rect.insetBy(dx: -(tolerance + item.lineWidth), let outer = item.rect.insetBy(dx: -(tolerance + item.lineWidth),
dy: -(tolerance + item.lineWidth)) dy: -(tolerance + item.lineWidth))
+20 -15
View File
@@ -1,6 +1,10 @@
import AppKit import AppKit
import SwiftUI import SwiftUI
private final class FlippedContainerView: NSView {
override var isFlipped: Bool { true }
}
final class EditorWindowController: NSWindowController, NSWindowDelegate { final class EditorWindowController: NSWindowController, NSWindowDelegate {
private let annotationDoc = AnnotationDocument() private let annotationDoc = AnnotationDocument()
@@ -19,7 +23,7 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate {
let maxHeight = screenFrame.height * 0.8 let maxHeight = screenFrame.height * 0.8
let scale = min(maxWidth / image.size.width, maxHeight / image.size.height, 1.0) let scale = min(maxWidth / image.size.width, maxHeight / image.size.height, 1.0)
let windowWidth = max(image.size.width * scale, 600) let windowWidth = max(image.size.width * scale, 600)
let windowHeight = max(image.size.height * scale, 400) + 52 // toolbar height let windowHeight = max(image.size.height * scale, 400) + 52
let window = NSWindow( let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight), contentRect: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight),
@@ -40,7 +44,6 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate {
canvasView.onDocumentChanged = { [weak self] in canvasView.onDocumentChanged = { [weak self] in
self?.canvasView.needsDisplay = true self?.canvasView.needsDisplay = true
} }
canvasView.autoresizingMask = [.width, .height]
let toolbarHosting = NSHostingView(rootView: EditorToolbarView( let toolbarHosting = NSHostingView(rootView: EditorToolbarView(
document: annotationDoc, document: annotationDoc,
@@ -48,23 +51,25 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate {
onCopyToClipboard: { [weak self] in self?.copyToClipboard() }, onCopyToClipboard: { [weak self] in self?.copyToClipboard() },
onCancel: { [weak self] in self?.cancel() } onCancel: { [weak self] in self?.cancel() }
)) ))
toolbarHosting.translatesAutoresizingMaskIntoConstraints = true toolbarHosting.translatesAutoresizingMaskIntoConstraints = false
canvasView.translatesAutoresizingMaskIntoConstraints = false
let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
container.autoresizingMask = [.width, .height]
toolbarHosting.frame = NSRect(x: 0, y: container.bounds.height - 52,
width: container.bounds.width, height: 52)
toolbarHosting.autoresizingMask = [.width, .minYMargin]
canvasView.frame = NSRect(x: 0, y: 0,
width: container.bounds.width,
height: container.bounds.height - 52)
canvasView.autoresizingMask = [.width, .height]
let container = FlippedContainerView()
container.addSubview(toolbarHosting) container.addSubview(toolbarHosting)
container.addSubview(canvasView) container.addSubview(canvasView)
window.contentView = container window.contentView = container
NSLayoutConstraint.activate([
toolbarHosting.topAnchor.constraint(equalTo: container.topAnchor),
toolbarHosting.leadingAnchor.constraint(equalTo: container.leadingAnchor),
toolbarHosting.trailingAnchor.constraint(equalTo: container.trailingAnchor),
toolbarHosting.heightAnchor.constraint(equalToConstant: 52),
canvasView.topAnchor.constraint(equalTo: toolbarHosting.bottomAnchor),
canvasView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
canvasView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
canvasView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
} }
@available(*, unavailable) @available(*, unavailable)
+17
View File
@@ -1,11 +1,28 @@
import AppKit import AppKit
import ServiceManagement
import SwiftUI import SwiftUI
struct SettingsView: View { struct SettingsView: View {
@EnvironmentObject var coordinator: AppCoordinator @EnvironmentObject var coordinator: AppCoordinator
@State private var launchAtLogin = SMAppService.mainApp.status == .enabled
var body: some View { var body: some View {
Form { Form {
Section("Allgemein") {
Toggle("Mit dem System starten", isOn: $launchAtLogin)
.onChange(of: launchAtLogin) { _, newValue in
do {
if newValue {
try SMAppService.mainApp.register()
} else {
try SMAppService.mainApp.unregister()
}
} catch {
launchAtLogin = SMAppService.mainApp.status == .enabled
}
}
}
Section("Nach der Aufnahme") { Section("Nach der Aufnahme") {
Picker("Aktion:", selection: $coordinator.outputAction) { Picker("Aktion:", selection: $coordinator.outputAction) {
ForEach(OutputAction.allCases) { action in ForEach(OutputAction.allCases) { action in
+2 -2
View File
@@ -26,8 +26,8 @@ targets:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: it.wende.snipmc PRODUCT_BUNDLE_IDENTIFIER: it.wende.snipmc
PRODUCT_NAME: SnipMC PRODUCT_NAME: SnipMC
MARKETING_VERSION: "1.0.1" MARKETING_VERSION: "1.0.2"
CURRENT_PROJECT_VERSION: "2" CURRENT_PROJECT_VERSION: "3"
SWIFT_VERSION: "5.0" SWIFT_VERSION: "5.0"
MACOSX_DEPLOYMENT_TARGET: "14.0" MACOSX_DEPLOYMENT_TARGET: "14.0"
ENABLE_HARDENED_RUNTIME: false ENABLE_HARDENED_RUNTIME: false