Skip to content

Commit 34aed1b

Browse files
feat: bundle sr + sr-mcp inside .app, shared UserDefaults license
Phase 1: Bundle binaries - build.sh: builds and copies sr/sr-mcp into .app/Contents/MacOS/ - release.sh: copies binaries into .app before code signing - release.yml: CI copies binaries into .app bundle Phase 2: CLI installer - CLIInstaller.swift: creates /usr/local/bin symlinks to bundled binaries - SettingsView: CLI Tools section with install/uninstall + status Phase 3: Shared license storage - SharedDefaults.swift: UserDefaults suite shared by all 3 binaries - LicenseActivator.swift: app uses shared suite - LicenseManager.swift: MCP uses shared suite - Auto-migration from legacy license.json Activating license in the app instantly works for sr-mcp, and vice versa.
1 parent b6ffcbe commit 34aed1b

8 files changed

Lines changed: 416 additions & 80 deletions

File tree

.github/workflows/release.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ jobs:
9191
9292
mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}"
9393
cp ".build/xcode-release/Build/Products/Release/${APP_NAME}" "${MACOS_DIR}/${APP_NAME}"
94+
95+
# Bundle CLI and MCP inside .app
96+
cp .build/sr "${MACOS_DIR}/sr"
97+
cp .build/sr-mcp "${MACOS_DIR}/sr-mcp"
98+
echo "✅ sr and sr-mcp bundled in .app"
99+
94100
cp Resources/Info.plist "${CONTENTS_DIR}/Info.plist"
95101
96102
[ -f "Resources/AppIcon.icns" ] && cp Resources/AppIcon.icns "${RESOURCES_DIR}/AppIcon.icns"

Sources/App/CLIInstaller.swift

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import Foundation
2+
import SwiftUI
3+
4+
/// Manages installation of CLI tools (sr, sr-mcp) from the app bundle
5+
/// into /usr/local/bin via symlinks.
6+
@MainActor
7+
final class CLIInstaller: ObservableObject {
8+
static let shared = CLIInstaller()
9+
10+
@Published var srInstalled: Bool = false
11+
@Published var mcpInstalled: Bool = false
12+
@Published var isInstalling: Bool = false
13+
@Published var statusMessage: String?
14+
15+
private let installDir = "/usr/local/bin"
16+
17+
private init() {
18+
checkInstallStatus()
19+
}
20+
21+
/// The path to the running app's MacOS directory.
22+
private var macOSDir: String? {
23+
Bundle.main.bundlePath.appending("/Contents/MacOS")
24+
}
25+
26+
// MARK: - Check Status
27+
28+
func checkInstallStatus() {
29+
srInstalled = isSymlinkValid(name: "sr")
30+
mcpInstalled = isSymlinkValid(name: "sr-mcp")
31+
}
32+
33+
private func isSymlinkValid(name: String) -> Bool {
34+
let linkPath = "\(installDir)/\(name)"
35+
guard let dest = try? FileManager.default.destinationOfSymbolicLink(atPath: linkPath) else {
36+
return false
37+
}
38+
// Valid if it points to our app bundle
39+
if let macOS = macOSDir {
40+
return dest == "\(macOS)/\(name)"
41+
}
42+
return false
43+
}
44+
45+
// MARK: - Install
46+
47+
func install() {
48+
guard let macOS = macOSDir else {
49+
statusMessage = "Cannot determine app bundle path"
50+
return
51+
}
52+
53+
isInstalling = true
54+
statusMessage = nil
55+
56+
// Build a script that creates the symlinks
57+
var commands: [String] = []
58+
commands.append("mkdir -p '\(installDir)'")
59+
60+
for name in ["sr", "sr-mcp"] {
61+
let source = "\(macOS)/\(name)"
62+
let link = "\(installDir)/\(name)"
63+
64+
// Check the binary exists in our bundle
65+
guard FileManager.default.fileExists(atPath: source) else {
66+
statusMessage = "\(name) not found in app bundle"
67+
isInstalling = false
68+
return
69+
}
70+
71+
commands.append("ln -sf '\(source)' '\(link)'")
72+
}
73+
74+
let script = commands.joined(separator: " && ")
75+
76+
// Try without admin first
77+
let process = Process()
78+
process.launchPath = "/bin/sh"
79+
process.arguments = ["-c", script]
80+
81+
let pipe = Pipe()
82+
process.standardError = pipe
83+
84+
do {
85+
try process.run()
86+
process.waitUntilExit()
87+
88+
if process.terminationStatus == 0 {
89+
checkInstallStatus()
90+
statusMessage = "CLI tools installed successfully!"
91+
isInstalling = false
92+
return
93+
}
94+
} catch {}
95+
96+
// Need admin privileges — use osascript
97+
let adminScript = "do shell script \"\(script)\" with administrator privileges"
98+
var error: NSDictionary?
99+
if let appleScript = NSAppleScript(source: adminScript) {
100+
appleScript.executeAndReturnError(&error)
101+
if let error = error {
102+
statusMessage = "Install failed: \(error[NSAppleScript.errorMessage] ?? "unknown error")"
103+
} else {
104+
checkInstallStatus()
105+
statusMessage = "CLI tools installed successfully!"
106+
}
107+
} else {
108+
statusMessage = "Failed to create install script"
109+
}
110+
111+
isInstalling = false
112+
}
113+
114+
// MARK: - Uninstall
115+
116+
func uninstall() {
117+
var commands: [String] = []
118+
for name in ["sr", "sr-mcp"] {
119+
let link = "\(installDir)/\(name)"
120+
if isSymlinkValid(name: name) {
121+
commands.append("rm '\(link)'")
122+
}
123+
}
124+
125+
guard !commands.isEmpty else { return }
126+
127+
let script = commands.joined(separator: " && ")
128+
let adminScript = "do shell script \"\(script)\" with administrator privileges"
129+
var error: NSDictionary?
130+
if let appleScript = NSAppleScript(source: adminScript) {
131+
appleScript.executeAndReturnError(&error)
132+
}
133+
134+
checkInstallStatus()
135+
statusMessage = nil
136+
}
137+
}

Sources/App/LicenseActivator.swift

Lines changed: 10 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import Foundation
22
import SwiftUI
33

44
/// Lightweight license activation for the main app.
5-
/// Shares ~/.screenrecorder/license.json with sr-mcp so activating
6-
/// in either place works for both.
5+
/// Uses SharedDefaults (UserDefaults suite) so the license is instantly
6+
/// available to sr CLI and sr-mcp without file sharing.
77
@MainActor
88
final class LicenseActivator: ObservableObject {
99
static let shared = LicenseActivator()
@@ -15,21 +15,15 @@ final class LicenseActivator: ObservableObject {
1515
@Published var errorMessage: String?
1616
@Published var successMessage: String?
1717

18-
private let baseDir: URL
19-
private let licensePath: URL
20-
2118
/// License server URL — override via SR_LICENSE_SERVER env var
2219
private var serverURL: String {
2320
ProcessInfo.processInfo.environment["SR_LICENSE_SERVER"]
2421
?? "https://license.screenrecorder.dev"
2522
}
2623

2724
private init() {
28-
let home = FileManager.default.homeDirectoryForCurrentUser
29-
baseDir = home.appendingPathComponent(".screenrecorder")
30-
licensePath = baseDir.appendingPathComponent("license.json")
31-
32-
try? FileManager.default.createDirectory(at: baseDir, withIntermediateDirectories: true)
25+
// Migrate from legacy JSON if needed
26+
SharedDefaults.migrateFromJSON()
3327
loadCached()
3428
}
3529

@@ -59,13 +53,11 @@ final class LicenseActivator: ObservableObject {
5953
let response = try JSONDecoder().decode(ValidationResponse.self, from: data)
6054

6155
if response.valid {
62-
let cache = LicenseFile(
56+
SharedDefaults.saveLicense(
6357
key: trimmed,
6458
plan: response.plan,
65-
email: response.email,
66-
validatedAt: Date()
59+
email: response.email
6760
)
68-
try saveCache(cache)
6961
plan = response.plan
7062
email = response.email
7163
isActivated = true
@@ -82,7 +74,7 @@ final class LicenseActivator: ObservableObject {
8274

8375
/// Deactivate the local license.
8476
func deactivate() {
85-
try? FileManager.default.removeItem(at: licensePath)
77+
SharedDefaults.removeLicense()
8678
plan = "none"
8779
email = ""
8880
isActivated = false
@@ -93,31 +85,14 @@ final class LicenseActivator: ObservableObject {
9385
// MARK: - Cache
9486

9587
private func loadCached() {
96-
guard let data = try? Data(contentsOf: licensePath),
97-
let cache = try? JSONDecoder().decode(LicenseFile.self, from: data)
98-
else { return }
99-
100-
plan = cache.plan
101-
email = cache.email
88+
guard SharedDefaults.isActivated else { return }
89+
plan = SharedDefaults.licensePlan
90+
email = SharedDefaults.licenseEmail
10291
isActivated = true
10392
}
10493

105-
private func saveCache(_ cache: LicenseFile) throws {
106-
let encoder = JSONEncoder()
107-
encoder.dateEncodingStrategy = .iso8601
108-
let data = try encoder.encode(cache)
109-
try data.write(to: licensePath, options: .atomic)
110-
}
111-
11294
// MARK: - Models
11395

114-
private struct LicenseFile: Codable {
115-
let key: String
116-
let plan: String
117-
let email: String
118-
let validatedAt: Date
119-
}
120-
12196
private struct ValidationResponse: Codable {
12297
let valid: Bool
12398
let plan: String

Sources/App/SharedDefaults.swift

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import Foundation
2+
3+
/// Shared UserDefaults suite for cross-process license data.
4+
/// Used by ScreenRecorder.app, sr CLI, and sr-mcp server.
5+
///
6+
/// Stores data at ~/Library/Preferences/com.codeitlikemiley.screenrecorder.shared.plist
7+
/// Works without sandboxing or App Group entitlements.
8+
enum SharedDefaults {
9+
static let suiteName = "com.codeitlikemiley.screenrecorder.shared"
10+
static let suite = UserDefaults(suiteName: suiteName)!
11+
12+
enum Keys {
13+
static let licenseKey = "license_key"
14+
static let licensePlan = "license_plan"
15+
static let licenseEmail = "license_email"
16+
static let licenseValidatedAt = "license_validated_at"
17+
}
18+
19+
// MARK: - Read
20+
21+
static var licenseKey: String? {
22+
suite.string(forKey: Keys.licenseKey)
23+
}
24+
25+
static var licensePlan: String {
26+
suite.string(forKey: Keys.licensePlan) ?? "none"
27+
}
28+
29+
static var licenseEmail: String {
30+
suite.string(forKey: Keys.licenseEmail) ?? ""
31+
}
32+
33+
static var licenseValidatedAt: Date? {
34+
suite.object(forKey: Keys.licenseValidatedAt) as? Date
35+
}
36+
37+
static var isActivated: Bool {
38+
licenseKey != nil
39+
}
40+
41+
// MARK: - Write
42+
43+
static func saveLicense(key: String, plan: String, email: String) {
44+
suite.set(key, forKey: Keys.licenseKey)
45+
suite.set(plan, forKey: Keys.licensePlan)
46+
suite.set(email, forKey: Keys.licenseEmail)
47+
suite.set(Date(), forKey: Keys.licenseValidatedAt)
48+
suite.synchronize()
49+
}
50+
51+
static func updatePlan(_ plan: String) {
52+
suite.set(plan, forKey: Keys.licensePlan)
53+
suite.set(Date(), forKey: Keys.licenseValidatedAt)
54+
suite.synchronize()
55+
}
56+
57+
static func removeLicense() {
58+
suite.removeObject(forKey: Keys.licenseKey)
59+
suite.removeObject(forKey: Keys.licensePlan)
60+
suite.removeObject(forKey: Keys.licenseEmail)
61+
suite.removeObject(forKey: Keys.licenseValidatedAt)
62+
suite.synchronize()
63+
}
64+
65+
// MARK: - Migration from legacy license.json
66+
67+
/// Migrates license data from ~/.screenrecorder/license.json to UserDefaults suite.
68+
/// Removes the old file after successful migration.
69+
static func migrateFromJSON() {
70+
guard licenseKey == nil else { return } // Already have data
71+
72+
let home = FileManager.default.homeDirectoryForCurrentUser
73+
let jsonPath = home
74+
.appendingPathComponent(".screenrecorder")
75+
.appendingPathComponent("license.json")
76+
77+
guard let data = try? Data(contentsOf: jsonPath),
78+
let json = try? JSONDecoder().decode(LegacyLicense.self, from: data)
79+
else { return }
80+
81+
saveLicense(key: json.key, plan: json.plan, email: json.email)
82+
try? FileManager.default.removeItem(at: jsonPath)
83+
84+
fputs("Migrated license from license.json to UserDefaults suite\n", stderr)
85+
}
86+
87+
private struct LegacyLicense: Codable {
88+
let key: String
89+
let plan: String
90+
let email: String
91+
let validatedAt: Date?
92+
}
93+
}

0 commit comments

Comments
 (0)