Initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user