Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
188b952be7 | ||
|
|
8d6c2213ad | ||
|
|
519706dd41 | ||
|
|
0fce3e6d28 | ||
|
|
4267650739 | ||
|
|
052663b356 | ||
|
|
827af0b239 |
@@ -0,0 +1,119 @@
|
||||
# SnipMC
|
||||
|
||||
**macOS Menu Bar Screenshot Tool mit integriertem Bildeditor**
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
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.
|
||||
@@ -274,7 +274,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
ENABLE_HARDENED_RUNTIME = NO;
|
||||
INFOPLIST_FILE = Generated/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -282,7 +282,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.0.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = it.wende.snipmc;
|
||||
PRODUCT_NAME = SnipMC;
|
||||
SDKROOT = macosx;
|
||||
@@ -352,7 +352,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
ENABLE_HARDENED_RUNTIME = NO;
|
||||
INFOPLIST_FILE = Generated/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -360,7 +360,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.0.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = it.wende.snipmc;
|
||||
PRODUCT_NAME = SnipMC;
|
||||
SDKROOT = macosx;
|
||||
|
||||
@@ -122,18 +122,6 @@ struct HelpTab: View {
|
||||
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") {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("\u{2022} Berechtigung Bildschirmaufnahme erforderlich (Systemeinstellungen \u{2192} Datenschutz & Sicherheit)")
|
||||
|
||||
@@ -242,24 +242,41 @@ final class AppCoordinator: ObservableObject {
|
||||
private func openEditor(image: NSImage, sourceURL: URL) {
|
||||
let controller = EditorWindowController(image: image, sourceURL: sourceURL) { [weak self] annotatedImage in
|
||||
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
|
||||
controller.showEditor()
|
||||
}
|
||||
|
||||
private func saveAnnotatedImage(_ image: NSImage, replacingFileAt url: URL) {
|
||||
guard let tiffData = image.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiffData) else { return }
|
||||
|
||||
private func saveAnnotatedImage(_ image: NSImage, to url: URL) {
|
||||
let bitmap: NSBitmapImageRep
|
||||
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 properties: [NSBitmapImageRep.PropertyKey: Any] = fileType == .jpeg
|
||||
? [.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 {
|
||||
try data.write(to: url, options: .atomic)
|
||||
NSLog("SnipMC: saved to \(url.path)")
|
||||
} catch {
|
||||
NSLog("SnipMC: failed to save annotated image: \(error)")
|
||||
}
|
||||
|
||||
@@ -108,7 +108,8 @@ final class AnnotationCanvasView: NSView {
|
||||
let handleRect: CGRect
|
||||
if item.kind == .arrow {
|
||||
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),
|
||||
width: abs(end.x - start.x), height: abs(end.y - start.y))
|
||||
} else if item.kind == .text {
|
||||
|
||||
@@ -26,19 +26,39 @@ enum AnnotationRenderer {
|
||||
}
|
||||
|
||||
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 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 {
|
||||
for item in annotations {
|
||||
draw(item, in: context)
|
||||
}
|
||||
ctx.draw(srcCG, in: CGRect(x: 0, y: 0, width: w, height: h))
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -46,7 +66,8 @@ enum AnnotationRenderer {
|
||||
|
||||
private static func drawArrow(_ item: AnnotationItem, in context: CGContext) {
|
||||
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.addLine(to: end)
|
||||
@@ -96,9 +117,11 @@ enum AnnotationRenderer {
|
||||
static func hitTest(point: CGPoint, item: AnnotationItem, tolerance: CGFloat = 6) -> Bool {
|
||||
switch item.kind {
|
||||
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,
|
||||
from: item.rect.origin,
|
||||
to: CGPoint(x: item.rect.maxX, y: item.rect.maxY)) < tolerance + item.lineWidth
|
||||
to: arrowEnd) < tolerance + item.lineWidth
|
||||
case .rectangle:
|
||||
let outer = item.rect.insetBy(dx: -(tolerance + item.lineWidth),
|
||||
dy: -(tolerance + item.lineWidth))
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
private final class FlippedContainerView: NSView {
|
||||
override var isFlipped: Bool { true }
|
||||
}
|
||||
|
||||
final class EditorWindowController: NSWindowController, NSWindowDelegate {
|
||||
|
||||
private let annotationDoc = AnnotationDocument()
|
||||
@@ -19,7 +23,7 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate {
|
||||
let maxHeight = screenFrame.height * 0.8
|
||||
let scale = min(maxWidth / image.size.width, maxHeight / image.size.height, 1.0)
|
||||
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(
|
||||
contentRect: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight),
|
||||
@@ -40,7 +44,6 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate {
|
||||
canvasView.onDocumentChanged = { [weak self] in
|
||||
self?.canvasView.needsDisplay = true
|
||||
}
|
||||
canvasView.autoresizingMask = [.width, .height]
|
||||
|
||||
let toolbarHosting = NSHostingView(rootView: EditorToolbarView(
|
||||
document: annotationDoc,
|
||||
@@ -48,23 +51,25 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate {
|
||||
onCopyToClipboard: { [weak self] in self?.copyToClipboard() },
|
||||
onCancel: { [weak self] in self?.cancel() }
|
||||
))
|
||||
toolbarHosting.translatesAutoresizingMaskIntoConstraints = true
|
||||
|
||||
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]
|
||||
toolbarHosting.translatesAutoresizingMaskIntoConstraints = false
|
||||
canvasView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let container = FlippedContainerView()
|
||||
container.addSubview(toolbarHosting)
|
||||
container.addSubview(canvasView)
|
||||
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)
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
import AppKit
|
||||
import ServiceManagement
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject var coordinator: AppCoordinator
|
||||
@State private var launchAtLogin = SMAppService.mainApp.status == .enabled
|
||||
|
||||
var body: some View {
|
||||
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") {
|
||||
Picker("Aktion:", selection: $coordinator.outputAction) {
|
||||
ForEach(OutputAction.allCases) { action in
|
||||
|
||||
+2
-2
@@ -26,8 +26,8 @@ targets:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: it.wende.snipmc
|
||||
PRODUCT_NAME: SnipMC
|
||||
MARKETING_VERSION: "1.0.1"
|
||||
CURRENT_PROJECT_VERSION: "2"
|
||||
MARKETING_VERSION: "1.0.2"
|
||||
CURRENT_PROJECT_VERSION: "3"
|
||||
SWIFT_VERSION: "5.0"
|
||||
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||
ENABLE_HARDENED_RUNTIME: false
|
||||
|
||||
Reference in New Issue
Block a user