Skip to content

Commit 5dd0b0f

Browse files
committed
feat: harden screen capture and refresh Gemini models
1 parent f9e1d8f commit 5dd0b0f

8 files changed

Lines changed: 130 additions & 54 deletions

Build.xcconfig

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33

44
PRODUCT_BUNDLE_IDENTIFIER = app.samuelz12.screenscribe-dev
55

6-
MARKETING_VERSION = 2.0.10
7-
CURRENT_PROJECT_VERSION = 12
6+
MARKETING_VERSION = 2.1.0
7+
CURRENT_PROJECT_VERSION = 13
88

99
// Local.xcconfig is for developer-specific overrides.
1010
#include? "Local.xcconfig"

ScreenScribe/Sources/Config.swift

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ struct GeminiModel: Identifiable {
3636

3737
/// Central configuration access point for the application
3838
enum Config {
39+
static let defaultGeminiModelID = "gemini-3-flash-preview"
40+
3941
/// The Gemini API key loaded from Keychain or Secrets.plist
4042
@MainActor
4143
static var geminiAPIKey: String {
@@ -48,9 +50,29 @@ enum Config {
4850
/// Available Gemini models to choose from
4951
static let availableGeminiModels: [GeminiModel] = [
5052
.init(id: "gemini-3-flash-preview", label: "Gemini 3 Flash", note: "Best balance"),
51-
.init(id: "gemini-3-pro-preview", label: "Gemini 3 Pro", note: "Most capable"),
52-
.init(id: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash-Lite", note: "Fastest"),
53+
.init(id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro", note: "Most capable"),
54+
.init(id: "gemini-3.1-flash-lite-preview", label: "Gemini 3.1 Flash-Lite", note: "Fastest"),
5355
]
56+
57+
static func migratedGeminiModelID(_ modelID: String) -> String {
58+
switch modelID {
59+
case "gemini-3-pro-preview":
60+
return "gemini-3.1-pro-preview"
61+
case "gemini-2.5-flash-lite":
62+
return "gemini-3.1-flash-lite-preview"
63+
default:
64+
return modelID
65+
}
66+
}
67+
68+
static func resolvedGeminiModelID(from storedModel: String?) -> String {
69+
let candidate = migratedGeminiModelID(storedModel ?? defaultGeminiModelID)
70+
if availableGeminiModels.contains(where: { $0.id == candidate }) {
71+
return candidate
72+
}
73+
74+
return defaultGeminiModelID
75+
}
5476

5577
/// Get the Gemini API endpoint for the specified model
5678
static func geminiEndpoint(for model: String) -> String {

ScreenScribe/Sources/Services/GeminiService.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ struct GeminiService {
6464
}
6565

6666
// Get the selected model from UserDefaults
67-
let model = UserDefaults.standard.string(forKey: "geminiModel") ?? "gemini-2.0-flash"
67+
let model = Config.resolvedGeminiModelID(
68+
from: UserDefaults.standard.string(forKey: "geminiModel")
69+
)
6870

6971
let payload: [String: Any] = [
7072
"contents": [[
@@ -141,4 +143,4 @@ struct GeminiService {
141143

142144
throw GeminiAPIError.networkError(NSError(domain: "com.example.error", code: 0, userInfo: nil))
143145
}
144-
}
146+
}

ScreenScribe/Sources/Services/ScreenCaptureBackend.swift

Lines changed: 25 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,17 @@ enum ScreenCaptureBackend: String, Equatable, CustomStringConvertible {
1515
}
1616
}
1717

18+
struct ScreenCaptureCLIArguments {
19+
static func selectionArguments(outputURL: URL) -> [String] {
20+
["-i", "-s", "-x", "-t", "png", outputURL.path]
21+
}
22+
}
23+
1824
struct ScreenCaptureStrategy {
1925
static func preferred(
2026
for version: OperatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion
2127
) -> ScreenCaptureBackend {
22-
if version.majorVersion > 15 {
23-
return .nativeRegionSelection
24-
}
25-
26-
if version.majorVersion == 15 && version.minorVersion >= 2 {
27-
return .nativeRegionSelection
28-
}
29-
28+
_ = version
3029
return .legacyScreencaptureCLI
3130
}
3231
}
@@ -37,17 +36,7 @@ final class ScreenCaptureService {
3736
private let selector = ScreenRegionSelector()
3837

3938
func captureSelectionImage() async -> NSImage? {
40-
switch ScreenCaptureStrategy.preferred() {
41-
case .nativeRegionSelection:
42-
if #available(macOS 15.2, *) {
43-
return await captureWithNativeRegionSelection()
44-
}
45-
46-
Logger.log(.error, "Native region capture was selected on an unsupported macOS version")
47-
return await captureWithLegacyCLI()
48-
case .legacyScreencaptureCLI:
49-
return await captureWithLegacyCLI()
50-
}
39+
return await captureWithLegacyCLI()
5140
}
5241

5342
@available(macOS 15.2, *)
@@ -72,28 +61,39 @@ final class ScreenCaptureService {
7261
}
7362

7463
private func captureWithLegacyCLI() async -> NSImage? {
75-
let initialChangeCount = NSPasteboard.general.changeCount
64+
let outputURL = FileManager.default.temporaryDirectory
65+
.appendingPathComponent("screenscribe-\(UUID().uuidString)")
66+
.appendingPathExtension("png")
7667

7768
return await withCheckedContinuation { (continuation: CheckedContinuation<NSImage?, Never>) in
7869
let task = Process()
7970
task.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
80-
task.arguments = ["-i", "-c", "-x"]
81-
task.terminationHandler = { _ in
71+
task.arguments = ScreenCaptureCLIArguments.selectionArguments(outputURL: outputURL)
72+
task.terminationHandler = { process in
8273
DispatchQueue.main.async {
83-
let pasteboard = NSPasteboard.general
84-
guard pasteboard.changeCount != initialChangeCount else {
74+
defer {
75+
try? FileManager.default.removeItem(at: outputURL)
76+
}
77+
78+
guard process.terminationStatus == 0 else {
79+
continuation.resume(returning: nil)
80+
return
81+
}
82+
83+
guard let data = try? Data(contentsOf: outputURL) else {
8584
continuation.resume(returning: nil)
8685
return
8786
}
8887

89-
continuation.resume(returning: Self.pasteboardImage(from: pasteboard))
88+
continuation.resume(returning: NSImage(data: data))
9089
}
9190
}
9291

9392
do {
9493
try task.run()
9594
} catch {
9695
Logger.log(.error, "Legacy screencapture launch failed: \(error.localizedDescription)")
96+
try? FileManager.default.removeItem(at: outputURL)
9797
continuation.resume(returning: nil)
9898
}
9999
}
@@ -118,19 +118,6 @@ final class ScreenCaptureService {
118118
}
119119
}
120120

121-
private static func pasteboardImage(from pasteboard: NSPasteboard) -> NSImage? {
122-
if let data = pasteboard.data(forType: .fileURL),
123-
let string = String(data: data, encoding: .utf8),
124-
let url = URL(string: string) {
125-
return NSImage(contentsOf: url)
126-
}
127-
128-
if let data = pasteboard.data(forType: .tiff) ?? pasteboard.data(forType: .png) {
129-
return NSImage(data: data)
130-
}
131-
132-
return (pasteboard.readObjects(forClasses: [NSImage.self]) as? [NSImage])?.first
133-
}
134121
}
135122

136123
private enum ScreenCaptureServiceError: LocalizedError {

ScreenScribe/Sources/Settings/SettingsManager.swift

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,11 @@ final class SettingsManager: ObservableObject {
4747
UserDefaults.standard.removeObject(forKey: "latexShortcut")
4848
}
4949

50-
let defaultModel = "gemini-3-flash-preview"
51-
let storedModel = UserDefaults.standard.string(forKey: "geminiModel") ?? defaultModel
52-
if Config.availableGeminiModels.contains(where: { $0.id == storedModel }) {
53-
selectedModel = storedModel
54-
} else {
55-
selectedModel = defaultModel
50+
let storedModel = UserDefaults.standard.string(forKey: "geminiModel")
51+
let resolvedModel = Config.resolvedGeminiModelID(from: storedModel)
52+
selectedModel = resolvedModel
53+
if storedModel != resolvedModel {
54+
UserDefaults.standard.set(resolvedModel, forKey: "geminiModel")
5655
}
5756

5857
if textShortcut == nil {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import Foundation
2+
3+
private func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
4+
guard condition() else {
5+
fputs("FAIL: \(message)\n", stderr)
6+
exit(1)
7+
}
8+
}
9+
10+
@main
11+
struct GeminiModelCatalogTests {
12+
static func main() {
13+
let modelIDs = Config.availableGeminiModels.map(\.id)
14+
15+
expect(modelIDs.contains("gemini-3-flash-preview"), "Gemini 3 Flash should remain available")
16+
expect(modelIDs.contains("gemini-3.1-pro-preview"), "Gemini 3.1 Pro Preview should be available")
17+
expect(modelIDs.contains("gemini-3.1-flash-lite-preview"), "Gemini 3.1 Flash-Lite Preview should be available")
18+
expect(!modelIDs.contains("gemini-3-pro-preview"), "Gemini 3 Pro Preview should be removed from the catalog")
19+
expect(!modelIDs.contains("gemini-2.5-flash-lite"), "Gemini 2.5 Flash-Lite should be removed from the catalog")
20+
21+
expect(
22+
Config.defaultGeminiModelID == "gemini-3-flash-preview",
23+
"The default Gemini model should remain Gemini 3 Flash"
24+
)
25+
expect(
26+
Config.migratedGeminiModelID("gemini-3-pro-preview") == "gemini-3.1-pro-preview",
27+
"Stored Gemini 3 Pro Preview selections should migrate to Gemini 3.1 Pro Preview"
28+
)
29+
expect(
30+
Config.migratedGeminiModelID("gemini-2.5-flash-lite") == "gemini-3.1-flash-lite-preview",
31+
"Stored Gemini 2.5 Flash-Lite selections should migrate to Gemini 3.1 Flash-Lite Preview"
32+
)
33+
expect(
34+
Config.migratedGeminiModelID("gemini-3-flash-preview") == "gemini-3-flash-preview",
35+
"Current supported selections should remain unchanged"
36+
)
37+
38+
print("GeminiModelCatalogTests passed")
39+
}
40+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import Foundation
2+
3+
private func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
4+
guard condition() else {
5+
fputs("FAIL: \(message)\n", stderr)
6+
exit(1)
7+
}
8+
}
9+
10+
@main
11+
struct ScreenCaptureCLIArgumentsTests {
12+
static func main() {
13+
let outputURL = URL(fileURLWithPath: "/tmp/screenscribe-capture.png")
14+
let arguments = ScreenCaptureCLIArguments.selectionArguments(outputURL: outputURL)
15+
16+
expect(arguments.contains("-i"), "interactive capture should stay enabled")
17+
expect(arguments.contains("-s"), "legacy capture should force rectangle selection mode")
18+
expect(arguments.contains("-x"), "legacy capture should stay silent")
19+
expect(arguments.contains("-t"), "legacy capture should force a stable file format")
20+
expect(arguments.contains("png"), "legacy capture should save PNG output")
21+
expect(!arguments.contains("-c"), "legacy capture should not route through the global clipboard")
22+
expect(arguments.last == outputURL.path, "legacy capture should write to the requested output file")
23+
24+
print("ScreenCaptureCLIArgumentsTests passed")
25+
}
26+
}

Tests/ScreenCaptureStrategyTests.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,16 @@ struct ScreenCaptureStrategyTests {
3434
ScreenCaptureStrategy.preferred(
3535
for: OperatingSystemVersion(majorVersion: 15, minorVersion: 2, patchVersion: 0)
3636
),
37-
.nativeRegionSelection,
38-
"macOS 15.2 should prefer the native ScreenCaptureKit region backend"
37+
.legacyScreencaptureCLI,
38+
"macOS 15.2 should keep using Apple's interactive screencapture backend"
3939
)
4040

4141
expectEqual(
4242
ScreenCaptureStrategy.preferred(
4343
for: OperatingSystemVersion(majorVersion: 26, minorVersion: 2, patchVersion: 0)
4444
),
45-
.nativeRegionSelection,
46-
"Tahoe-era macOS releases should stay on the native ScreenCaptureKit backend"
45+
.legacyScreencaptureCLI,
46+
"Modern macOS releases should keep using Apple's interactive screencapture backend"
4747
)
4848

4949
print("ScreenCaptureStrategyTests passed")

0 commit comments

Comments
 (0)