Initial commit

This commit is contained in:
Alex E. Tafoya
2026-09-17 15:15:45 -05:00
commit 87fe7927c4
36 changed files with 5113 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
// Capture.swift
// GreenshotMac
//
// Equivalent of Greenshot's ICapture / ICaptureDetails.
import AppKit
enum CaptureMode: String {
case region
case fullscreen
case window
case lastRegion
case importedFile
}
/// Metadata that travels with a capture; used by the filename pattern engine.
struct CaptureDetails {
var captureMode: CaptureMode = .region
var dateTime: Date = Date()
/// Title of the captured window, or the screen name for a full screen capture.
var title: String = ""
/// Bundle identifier of the captured application, when known.
var applicationBundleID: String?
/// Application name of the captured window, when known.
var applicationName: String?
/// Screen rectangle the capture was taken from, in global (top-left origin) coordinates.
var screenBounds: CGRect = .zero
/// Scale factor of the display the capture came from.
var scaleFactor: CGFloat = 2.0
/// Set once the capture has been written to disk.
var filePath: String?
}
/// A screenshot plus its metadata.
final class Capture {
var image: CGImage
var details: CaptureDetails
init(image: CGImage, details: CaptureDetails) {
self.image = image
self.details = details
}
var pixelSize: CGSize {
CGSize(width: image.width, height: image.height)
}
/// Size in points, i.e. pixels divided by the capture's backing scale factor.
var pointSize: CGSize {
CGSize(width: CGFloat(image.width) / details.scaleFactor,
height: CGFloat(image.height) / details.scaleFactor)
}
var nsImage: NSImage {
NSImage(cgImage: image, size: pointSize)
}
}
enum CaptureError: LocalizedError {
case permissionDenied
case noContent
case cancelled
case captureFailed(String)
var errorDescription: String? {
switch self {
case .permissionDenied:
return "GreenshotMac needs Screen Recording permission. Open System Settings › Privacy & Security › Screen Recording and enable GreenshotMac."
case .noContent:
return "Nothing was available to capture."
case .cancelled:
return "Capture cancelled."
case .captureFailed(let message):
return message
}
}
}
+314
View File
@@ -0,0 +1,314 @@
// Configuration.swift
// GreenshotMac
//
// Port of Greenshot's ICoreConfiguration / IEditorConfiguration onto UserDefaults.
import AppKit
enum OutputFormat: String, CaseIterable, Codable {
case png, jpg, tiff, bmp, gif
var displayName: String { rawValue.uppercased() }
var fileType: NSBitmapImageRep.FileType {
switch self {
case .png: return .png
case .jpg: return .jpeg
case .tiff: return .tiff
case .bmp: return .bmp
case .gif: return .gif
}
}
}
enum WindowCaptureMode: String, CaseIterable, Codable {
case screen // crop the window rectangle out of a full screen grab
case window // ask ScreenCaptureKit for the window itself (no shadow)
case windowWithShadow
}
/// Identifiers of the destinations a capture can be sent to.
enum DestinationID: String, CaseIterable, Codable {
case picker
case editor
case clipboard
case file
case fileWithDialog
case printer
case share
case email
case openWith
var title: String {
switch self {
case .picker: return "Show destination picker"
case .editor: return "Open in image editor"
case .clipboard: return "Copy to clipboard"
case .file: return "Save (auto-named)"
case .fileWithDialog: return "Save as…"
case .printer: return "Print"
case .share: return "Share…"
case .email: return "Send as email"
case .openWith: return "Open with…"
}
}
var symbolName: String {
switch self {
case .picker: return "list.bullet"
case .editor: return "pencil.tip.crop.circle"
case .clipboard: return "doc.on.clipboard"
case .file: return "square.and.arrow.down"
case .fileWithDialog: return "folder"
case .printer: return "printer"
case .share: return "square.and.arrow.up"
case .email: return "envelope"
case .openWith: return "arrow.up.forward.app"
}
}
}
/// A modifier + key combination used for a global hotkey.
struct HotkeySpec: Codable, Equatable {
var keyCode: UInt32
var modifiers: UInt32 // Carbon modifier mask
static let none = HotkeySpec(keyCode: 0, modifiers: 0)
var isEmpty: Bool { keyCode == 0 && modifiers == 0 }
}
final class Configuration {
static let shared = Configuration()
private let defaults = UserDefaults.standard
private init() {
defaults.register(defaults: [
Keys.outputFileFilenamePattern: "${capturetime:d\"yyyy-MM-dd HH_mm_ss\"}-${title}",
Keys.outputFileFormat: OutputFormat.png.rawValue,
Keys.outputFileJpegQuality: 80,
Keys.outputFilePath: FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first?.path
?? NSHomeDirectory(),
Keys.captureMousepointer: false,
Keys.captureDelayMillis: 0,
Keys.playCameraSound: true,
Keys.showFlashAnimation: true,
Keys.windowCaptureMode: WindowCaptureMode.window.rawValue,
Keys.defaultDestination: DestinationID.picker.rawValue,
Keys.pickerDestinations: [DestinationID.editor.rawValue,
DestinationID.file.rawValue,
DestinationID.clipboard.rawValue,
DestinationID.email.rawValue],
Keys.copyPathToClipboard: false,
Keys.editorLineThickness: 2,
Keys.editorLineColor: "#FF0000",
Keys.editorFillColor: "#00000000",
Keys.editorFontFamily: "Helvetica",
Keys.editorFontSize: 14,
Keys.editorBlurRadius: 12,
Keys.editorPixelSize: 8,
Keys.editorHighlightColor: "#FFFF00",
Keys.editorDropShadow: false,
Keys.showMagnifier: true,
Keys.showSelectionSize: true
])
}
enum Keys {
static let outputFileFilenamePattern = "OutputFileFilenamePattern"
static let outputFileFormat = "OutputFileFormat"
static let outputFileJpegQuality = "OutputFileJpegQuality"
static let outputFilePath = "OutputFilePath"
static let captureMousepointer = "CaptureMousepointer"
static let captureDelayMillis = "CaptureDelayMillis"
static let playCameraSound = "PlayCameraSound"
static let showFlashAnimation = "ShowFlashAnimation"
static let windowCaptureMode = "WindowCaptureMode"
static let defaultDestination = "DefaultDestination"
static let pickerDestinations = "PickerDestinations"
static let copyPathToClipboard = "CopyPathToClipboard"
static let editorLineThickness = "EditorLineThickness"
static let editorLineColor = "EditorLineColor"
static let editorFillColor = "EditorFillColor"
static let editorFontFamily = "EditorFontFamily"
static let editorFontSize = "EditorFontSize"
static let editorBlurRadius = "EditorBlurRadius"
static let editorPixelSize = "EditorPixelSize"
static let editorHighlightColor = "EditorHighlightColor"
static let editorDropShadow = "EditorDropShadow"
static let showMagnifier = "ShowMagnifier"
static let showSelectionSize = "ShowSelectionSize"
static let hotkeyRegion = "HotkeyRegion"
static let hotkeyFullscreen = "HotkeyFullscreen"
static let hotkeyWindow = "HotkeyWindow"
static let hotkeyLastRegion = "HotkeyLastRegion"
}
// MARK: - Output
var outputFileFilenamePattern: String {
get { defaults.string(forKey: Keys.outputFileFilenamePattern) ?? "" }
set { defaults.set(newValue, forKey: Keys.outputFileFilenamePattern) }
}
var outputFileFormat: OutputFormat {
get { OutputFormat(rawValue: defaults.string(forKey: Keys.outputFileFormat) ?? "png") ?? .png }
set { defaults.set(newValue.rawValue, forKey: Keys.outputFileFormat) }
}
var outputFileJpegQuality: Int {
get { defaults.integer(forKey: Keys.outputFileJpegQuality) }
set { defaults.set(newValue, forKey: Keys.outputFileJpegQuality) }
}
var outputFilePath: String {
get { defaults.string(forKey: Keys.outputFilePath) ?? NSHomeDirectory() }
set { defaults.set(newValue, forKey: Keys.outputFilePath) }
}
var copyPathToClipboard: Bool {
get { defaults.bool(forKey: Keys.copyPathToClipboard) }
set { defaults.set(newValue, forKey: Keys.copyPathToClipboard) }
}
// MARK: - Capture
var captureMousepointer: Bool {
get { defaults.bool(forKey: Keys.captureMousepointer) }
set { defaults.set(newValue, forKey: Keys.captureMousepointer) }
}
var captureDelayMillis: Int {
get { defaults.integer(forKey: Keys.captureDelayMillis) }
set { defaults.set(newValue, forKey: Keys.captureDelayMillis) }
}
var playCameraSound: Bool {
get { defaults.bool(forKey: Keys.playCameraSound) }
set { defaults.set(newValue, forKey: Keys.playCameraSound) }
}
var showFlashAnimation: Bool {
get { defaults.bool(forKey: Keys.showFlashAnimation) }
set { defaults.set(newValue, forKey: Keys.showFlashAnimation) }
}
var windowCaptureMode: WindowCaptureMode {
get { WindowCaptureMode(rawValue: defaults.string(forKey: Keys.windowCaptureMode) ?? "window") ?? .window }
set { defaults.set(newValue.rawValue, forKey: Keys.windowCaptureMode) }
}
var showMagnifier: Bool {
get { defaults.bool(forKey: Keys.showMagnifier) }
set { defaults.set(newValue, forKey: Keys.showMagnifier) }
}
var showSelectionSize: Bool {
get { defaults.bool(forKey: Keys.showSelectionSize) }
set { defaults.set(newValue, forKey: Keys.showSelectionSize) }
}
// MARK: - Destinations
var defaultDestination: DestinationID {
get { DestinationID(rawValue: defaults.string(forKey: Keys.defaultDestination) ?? "editor") ?? .editor }
set { defaults.set(newValue.rawValue, forKey: Keys.defaultDestination) }
}
var pickerDestinations: [DestinationID] {
get { (defaults.stringArray(forKey: Keys.pickerDestinations) ?? []).compactMap(DestinationID.init(rawValue:)) }
set { defaults.set(newValue.map(\.rawValue), forKey: Keys.pickerDestinations) }
}
// MARK: - Editor defaults
var editorLineThickness: Int {
get { defaults.integer(forKey: Keys.editorLineThickness) }
set { defaults.set(newValue, forKey: Keys.editorLineThickness) }
}
var editorLineColor: NSColor {
get { NSColor(hexString: defaults.string(forKey: Keys.editorLineColor) ?? "#FF0000") ?? .systemRed }
set { defaults.set(newValue.hexString, forKey: Keys.editorLineColor) }
}
var editorFillColor: NSColor {
get { NSColor(hexString: defaults.string(forKey: Keys.editorFillColor) ?? "#00000000") ?? .clear }
set { defaults.set(newValue.hexString, forKey: Keys.editorFillColor) }
}
var editorHighlightColor: NSColor {
get { NSColor(hexString: defaults.string(forKey: Keys.editorHighlightColor) ?? "#FFFF00") ?? .systemYellow }
set { defaults.set(newValue.hexString, forKey: Keys.editorHighlightColor) }
}
var editorFontFamily: String {
get { defaults.string(forKey: Keys.editorFontFamily) ?? "Helvetica" }
set { defaults.set(newValue, forKey: Keys.editorFontFamily) }
}
var editorFontSize: Int {
get { defaults.integer(forKey: Keys.editorFontSize) }
set { defaults.set(newValue, forKey: Keys.editorFontSize) }
}
var editorBlurRadius: Int {
get { defaults.integer(forKey: Keys.editorBlurRadius) }
set { defaults.set(newValue, forKey: Keys.editorBlurRadius) }
}
var editorPixelSize: Int {
get { defaults.integer(forKey: Keys.editorPixelSize) }
set { defaults.set(newValue, forKey: Keys.editorPixelSize) }
}
var editorDropShadow: Bool {
get { defaults.bool(forKey: Keys.editorDropShadow) }
set { defaults.set(newValue, forKey: Keys.editorDropShadow) }
}
// MARK: - Hotkeys
func hotkey(forKey key: String, default fallback: HotkeySpec) -> HotkeySpec {
guard let data = defaults.data(forKey: key),
let spec = try? JSONDecoder().decode(HotkeySpec.self, from: data) else { return fallback }
return spec
}
func setHotkey(_ spec: HotkeySpec, forKey key: String) {
if let data = try? JSONEncoder().encode(spec) {
defaults.set(data, forKey: key)
}
}
}
extension NSColor {
/// #RRGGBB or #RRGGBBAA
convenience init?(hexString: String) {
var hex = hexString.trimmingCharacters(in: .whitespacesAndNewlines)
if hex.hasPrefix("#") { hex.removeFirst() }
guard hex.count == 6 || hex.count == 8, let value = UInt64(hex, radix: 16) else { return nil }
let r, g, b, a: CGFloat
if hex.count == 6 {
r = CGFloat((value >> 16) & 0xFF) / 255
g = CGFloat((value >> 8) & 0xFF) / 255
b = CGFloat(value & 0xFF) / 255
a = 1
} else {
r = CGFloat((value >> 24) & 0xFF) / 255
g = CGFloat((value >> 16) & 0xFF) / 255
b = CGFloat((value >> 8) & 0xFF) / 255
a = CGFloat(value & 0xFF) / 255
}
self.init(srgbRed: r, green: g, blue: b, alpha: a)
}
var hexString: String {
guard let rgb = usingColorSpace(.sRGB) else { return "#000000FF" }
let r = Int((rgb.redComponent * 255).rounded())
let g = Int((rgb.greenComponent * 255).rounded())
let b = Int((rgb.blueComponent * 255).rounded())
let a = Int((rgb.alphaComponent * 255).rounded())
return String(format: "#%02X%02X%02X%02X", r, g, b, a)
}
}
+195
View File
@@ -0,0 +1,195 @@
// FilenamePattern.swift
// GreenshotMac
//
// Port of Greenshot's FilenameHelper: ${variable:parameters} expansion.
//
// Supported variables:
// ${capturetime} - capture timestamp, ${capturetime:d"yyyy-MM-dd HH_mm_ss"} for a custom format
// ${now} - current timestamp, same parameter syntax
// ${title} - window title of the capture
// ${appname} - application name of the captured window
// ${user} - short user name
// ${hostname} - computer name
// ${mode} - capture mode (region / window / fullscreen)
// ${NUM} - zero padded incrementing counter, ${NUM:d4} for four digits
// Parameters shared by all variables:
// :s<from>,<to> - substring
// :r<search>,<replace> - replace
// :t - trim
// :u / :l - upper / lower case
import Foundation
enum FilenamePattern {
private static let variableRegex = try! NSRegularExpression(
pattern: #"\$\{(?<variable>[^:}]+)[:]?(?<parameters>[^}]*)\}"#)
private static let counterKey = "FilenamePatternCounter"
private static let maxTitleLength = 80
/// Expands the pattern and returns a filename *without* extension.
static func filename(pattern: String, details: CaptureDetails) -> String {
let expanded = expand(pattern: pattern, details: details)
return makeSafe(expanded.isEmpty ? defaultName(details) : expanded)
}
/// Expands the pattern and appends the extension of the given format.
static func filename(pattern: String, details: CaptureDetails, format: OutputFormat) -> String {
filename(pattern: pattern, details: details) + "." + format.rawValue
}
static func defaultName(_ details: CaptureDetails) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH_mm_ss"
return formatter.string(from: details.dateTime)
}
static func expand(pattern: String, details: CaptureDetails) -> String {
let ns = pattern as NSString
var result = ""
var location = 0
variableRegex.enumerateMatches(in: pattern, range: NSRange(location: 0, length: ns.length)) { match, _, _ in
guard let match else { return }
result += ns.substring(with: NSRange(location: location, length: match.range.location - location))
let variable = ns.substring(with: match.range(withName: "variable"))
let parameters = match.range(withName: "parameters").location == NSNotFound
? "" : ns.substring(with: match.range(withName: "parameters"))
result += value(for: variable, parameters: parameters, details: details)
location = match.range.location + match.range.length
}
result += ns.substring(from: location)
return result
}
private static func value(for variable: String, parameters: String, details: CaptureDetails) -> String {
var replacement: String
switch variable.lowercased() {
case "capturetime":
replacement = formatted(date: details.dateTime, parameters: parameters)
case "now":
replacement = formatted(date: Date(), parameters: parameters)
case "title":
replacement = String(details.title.prefix(maxTitleLength))
case "appname", "application":
replacement = details.applicationName ?? ""
case "user", "username":
replacement = NSUserName()
case "hostname", "computername":
replacement = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
case "mode":
replacement = details.captureMode.rawValue
case "num":
replacement = counter(parameters: parameters)
default:
replacement = ProcessInfo.processInfo.environment[variable] ?? ""
}
return apply(parameters: parameters, to: replacement)
}
private static func formatted(date: Date, parameters: String) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
if let format = parameter(parameters, prefix: "d") {
formatter.dateFormat = format
} else {
formatter.dateFormat = "yyyy-MM-dd HH_mm_ss"
}
return formatter.string(from: date)
}
private static func counter(parameters: String) -> String {
let defaults = UserDefaults.standard
let next = defaults.integer(forKey: counterKey) + 1
defaults.set(next, forKey: counterKey)
let digits = Int(parameter(parameters, prefix: "d") ?? "") ?? 1
return String(format: "%0\(max(1, digits))d", next)
}
/// Extracts a single parameter such as d"yyyy-MM-dd" or d4 from the parameter string.
private static func parameter(_ parameters: String, prefix: String) -> String? {
for part in split(parameters) where part.hasPrefix(prefix) {
var value = String(part.dropFirst(prefix.count))
if value.hasPrefix("\"") && value.hasSuffix("\"") && value.count >= 2 {
value = String(value.dropFirst().dropLast())
}
return value
}
return nil
}
private static func apply(parameters: String, to input: String) -> String {
var value = input
for part in split(parameters) {
guard let marker = part.first else { continue }
let argument = String(part.dropFirst())
switch marker {
case "t":
value = value.trimmingCharacters(in: .whitespacesAndNewlines)
case "u":
value = value.uppercased()
case "l":
value = value.lowercased()
case "r":
let pieces = argument.split(separator: ",", maxSplits: 1, omittingEmptySubsequences: false)
if pieces.count == 2 {
value = value.replacingOccurrences(of: unquote(String(pieces[0])),
with: unquote(String(pieces[1])))
}
case "s":
let pieces = argument.split(separator: ",", maxSplits: 1, omittingEmptySubsequences: false)
let from = max(0, Int(pieces.first.map(String.init) ?? "") ?? 0)
let to = pieces.count == 2 ? (Int(String(pieces[1])) ?? value.count) : value.count
if from < value.count {
let start = value.index(value.startIndex, offsetBy: from)
let end = value.index(value.startIndex, offsetBy: min(max(to, from), value.count))
value = String(value[start..<end])
} else {
value = ""
}
default:
break
}
}
return value
}
/// Splits parameters on "," but keeps quoted sections together.
private static func split(_ parameters: String) -> [String] {
var parts: [String] = []
var current = ""
var inQuotes = false
for character in parameters {
if character == "\"" {
inQuotes.toggle()
current.append(character)
} else if character == "," && !inQuotes {
parts.append(current)
current = ""
} else {
current.append(character)
}
}
if !current.isEmpty { parts.append(current) }
return parts
}
private static func unquote(_ value: String) -> String {
guard value.count >= 2, value.hasPrefix("\""), value.hasSuffix("\"") else { return value }
return String(value.dropFirst().dropLast())
}
/// Strips characters that are illegal in a macOS filename.
static func makeSafe(_ filename: String) -> String {
var safe = filename
for character in [":", "/", "\\", "\0"] {
safe = safe.replacingOccurrences(of: character, with: "_")
}
safe = safe.trimmingCharacters(in: .whitespacesAndNewlines)
if safe.hasPrefix(".") { safe = "_" + safe.dropFirst() }
if safe.isEmpty { safe = "screenshot" }
return String(safe.prefix(200))
}
}
+136
View File
@@ -0,0 +1,136 @@
// HotkeyManager.swift
// GreenshotMac
//
// System wide hotkeys through the Carbon hotkey API, the macOS counterpart of
// Greenshot's HotkeyControl (RegisterHotKey on Win32).
import AppKit
import Carbon.HIToolbox
final class HotkeyManager {
static let shared = HotkeyManager()
private struct Registration {
let ref: EventHotKeyRef
let handler: () -> Void
}
private var registrations: [UInt32: Registration] = [:]
private var nextID: UInt32 = 1
private var eventHandler: EventHandlerRef?
private init() {}
/// Installs the process wide Carbon event handler. Safe to call more than once.
private func installHandlerIfNeeded() {
guard eventHandler == nil else { return }
var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard),
eventKind: UInt32(kEventHotKeyPressed))
let callback: EventHandlerUPP = { _, event, _ in
var hotkeyID = EventHotKeyID()
let status = GetEventParameter(event,
EventParamName(kEventParamDirectObject),
EventParamType(typeEventHotKeyID),
nil,
MemoryLayout<EventHotKeyID>.size,
nil,
&hotkeyID)
guard status == noErr else { return status }
DispatchQueue.main.async {
HotkeyManager.shared.fire(id: hotkeyID.id)
}
return noErr
}
InstallEventHandler(GetApplicationEventTarget(), callback, 1, &eventType, nil, &eventHandler)
}
fileprivate func fire(id: UInt32) {
registrations[id]?.handler()
}
/// Registers a hotkey and returns its identifier, or nil when the combination is taken.
@discardableResult
func register(_ spec: HotkeySpec, handler: @escaping () -> Void) -> UInt32? {
guard !spec.isEmpty else { return nil }
installHandlerIfNeeded()
let id = nextID
nextID += 1
var hotkeyRef: EventHotKeyRef?
let hotkeyID = EventHotKeyID(signature: OSType(0x47535354), id: id) // 'GSST'
let status = RegisterEventHotKey(spec.keyCode,
spec.modifiers,
hotkeyID,
GetApplicationEventTarget(),
0,
&hotkeyRef)
guard status == noErr, let hotkeyRef else { return nil }
registrations[id] = Registration(ref: hotkeyRef, handler: handler)
return id
}
func unregister(_ id: UInt32) {
guard let registration = registrations.removeValue(forKey: id) else { return }
UnregisterEventHotKey(registration.ref)
}
func unregisterAll() {
for id in registrations.keys { unregister(id) }
}
// MARK: - Display helpers
static func description(of spec: HotkeySpec) -> String {
guard !spec.isEmpty else { return "None" }
var text = ""
if spec.modifiers & UInt32(controlKey) != 0 { text += "⌃" }
if spec.modifiers & UInt32(optionKey) != 0 { text += "⌥" }
if spec.modifiers & UInt32(shiftKey) != 0 { text += "⇧" }
if spec.modifiers & UInt32(cmdKey) != 0 { text += "⌘" }
text += keyName(for: spec.keyCode)
return text
}
static func keyName(for keyCode: UInt32) -> String {
let names: [UInt32: String] = [
UInt32(kVK_ANSI_A): "A", UInt32(kVK_ANSI_B): "B", UInt32(kVK_ANSI_C): "C",
UInt32(kVK_ANSI_D): "D", UInt32(kVK_ANSI_E): "E", UInt32(kVK_ANSI_F): "F",
UInt32(kVK_ANSI_G): "G", UInt32(kVK_ANSI_H): "H", UInt32(kVK_ANSI_I): "I",
UInt32(kVK_ANSI_J): "J", UInt32(kVK_ANSI_K): "K", UInt32(kVK_ANSI_L): "L",
UInt32(kVK_ANSI_M): "M", UInt32(kVK_ANSI_N): "N", UInt32(kVK_ANSI_O): "O",
UInt32(kVK_ANSI_P): "P", UInt32(kVK_ANSI_Q): "Q", UInt32(kVK_ANSI_R): "R",
UInt32(kVK_ANSI_S): "S", UInt32(kVK_ANSI_T): "T", UInt32(kVK_ANSI_U): "U",
UInt32(kVK_ANSI_V): "V", UInt32(kVK_ANSI_W): "W", UInt32(kVK_ANSI_X): "X",
UInt32(kVK_ANSI_Y): "Y", UInt32(kVK_ANSI_Z): "Z",
UInt32(kVK_ANSI_1): "1", UInt32(kVK_ANSI_2): "2", UInt32(kVK_ANSI_3): "3",
UInt32(kVK_ANSI_4): "4", UInt32(kVK_ANSI_5): "5", UInt32(kVK_ANSI_6): "6",
UInt32(kVK_ANSI_7): "7", UInt32(kVK_ANSI_8): "8", UInt32(kVK_ANSI_9): "9",
UInt32(kVK_ANSI_0): "0",
UInt32(kVK_Space): "Space", UInt32(kVK_Return): "Return", UInt32(kVK_Escape): "Esc",
UInt32(kVK_F1): "F1", UInt32(kVK_F2): "F2", UInt32(kVK_F3): "F3", UInt32(kVK_F4): "F4",
UInt32(kVK_F5): "F5", UInt32(kVK_F6): "F6", UInt32(kVK_F7): "F7", UInt32(kVK_F8): "F8",
UInt32(kVK_F9): "F9", UInt32(kVK_F10): "F10", UInt32(kVK_F11): "F11", UInt32(kVK_F12): "F12"
]
return names[keyCode] ?? "Key \(keyCode)"
}
/// Converts AppKit modifier flags to the Carbon mask used by RegisterEventHotKey.
static func carbonModifiers(from flags: NSEvent.ModifierFlags) -> UInt32 {
var modifiers: UInt32 = 0
if flags.contains(.command) { modifiers |= UInt32(cmdKey) }
if flags.contains(.option) { modifiers |= UInt32(optionKey) }
if flags.contains(.control) { modifiers |= UInt32(controlKey) }
if flags.contains(.shift) { modifiers |= UInt32(shiftKey) }
return modifiers
}
}
/// Greenshot's Windows defaults (PrintScreen based) have no direct macOS equivalent,
/// so these mirror the Greenshot key layout using ⌘⇧ combinations that macOS leaves free.
enum DefaultHotkeys {
static let region = HotkeySpec(keyCode: UInt32(kVK_ANSI_4), modifiers: UInt32(cmdKey | shiftKey | optionKey))
static let fullscreen = HotkeySpec(keyCode: UInt32(kVK_ANSI_3), modifiers: UInt32(cmdKey | shiftKey | optionKey))
static let window = HotkeySpec(keyCode: UInt32(kVK_ANSI_5), modifiers: UInt32(cmdKey | shiftKey | optionKey))
static let lastRegion = HotkeySpec(keyCode: UInt32(kVK_ANSI_6), modifiers: UInt32(cmdKey | shiftKey | optionKey))
}
+88
View File
@@ -0,0 +1,88 @@
// ImageExporter.swift
// GreenshotMac
//
// Port of Greenshot's ImageIO: encoding a capture to the configured output format
// and writing it to the configured directory using the filename pattern.
import AppKit
import UniformTypeIdentifiers
enum ImageExporter {
static func data(from image: CGImage, format: OutputFormat, jpegQuality: Int) -> Data? {
let rep = NSBitmapImageRep(cgImage: image)
rep.size = CGSize(width: image.width, height: image.height)
var properties: [NSBitmapImageRep.PropertyKey: Any] = [:]
if format == .jpg {
properties[.compressionFactor] = NSNumber(value: Double(jpegQuality) / 100.0)
}
return rep.representation(using: format.fileType, properties: properties)
}
/// Flattens an image onto white, needed for formats without an alpha channel.
static func flattened(_ image: CGImage) -> CGImage {
guard let context = CGContext(data: nil,
width: image.width,
height: image.height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue) else { return image }
context.setFillColor(NSColor.white.cgColor)
context.fill(CGRect(x: 0, y: 0, width: image.width, height: image.height))
context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
return context.makeImage() ?? image
}
/// The URL a capture would be saved to with the current configuration.
static func suggestedURL(for details: CaptureDetails,
format: OutputFormat = Configuration.shared.outputFileFormat) -> URL {
let configuration = Configuration.shared
let name = FilenamePattern.filename(pattern: configuration.outputFileFilenamePattern,
details: details,
format: format)
let directory = URL(fileURLWithPath: configuration.outputFilePath, isDirectory: true)
return uniqueURL(directory.appendingPathComponent(name))
}
/// Appends " (2)", " (3)", … until the URL is free.
static func uniqueURL(_ url: URL) -> URL {
guard FileManager.default.fileExists(atPath: url.path) else { return url }
let directory = url.deletingLastPathComponent()
let ext = url.pathExtension
let base = url.deletingPathExtension().lastPathComponent
var index = 2
while index < 1000 {
let candidate = directory
.appendingPathComponent("\(base) (\(index))")
.appendingPathExtension(ext)
if !FileManager.default.fileExists(atPath: candidate.path) { return candidate }
index += 1
}
return url
}
@discardableResult
static func write(_ image: CGImage, to url: URL, format: OutputFormat) throws -> URL {
let configuration = Configuration.shared
let source = (format == .jpg || format == .bmp) ? flattened(image) : image
guard let data = data(from: source, format: format, jpegQuality: configuration.outputFileJpegQuality) else {
throw CaptureError.captureFailed("Could not encode the image as \(format.displayName).")
}
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: .atomic)
return url
}
static func format(forPathExtension pathExtension: String) -> OutputFormat {
switch pathExtension.lowercased() {
case "jpg", "jpeg": return .jpg
case "tif", "tiff": return .tiff
case "bmp": return .bmp
case "gif": return .gif
default: return .png
}
}
}
+26
View File
@@ -0,0 +1,26 @@
// LaunchAtLogin.swift
// GreenshotMac
import Foundation
import ServiceManagement
enum LaunchAtLogin {
static var isEnabled: Bool {
get { SMAppService.mainApp.status == .enabled }
set {
do {
if newValue {
if SMAppService.mainApp.status != .enabled {
try SMAppService.mainApp.register()
}
} else {
if SMAppService.mainApp.status == .enabled {
try SMAppService.mainApp.unregister()
}
}
} catch {
NSLog("GreenshotMac: could not change launch at login: \(error.localizedDescription)")
}
}
}
}