New updates

This commit is contained in:
Mark Gregory
2026-09-09 08:23:43 +01:00
parent 868604b848
commit 5b9c19c52f
107 changed files with 11126 additions and 8 deletions
+13 -2
View File
@@ -4,6 +4,7 @@ import SwiftData
struct ContentView: View {
let startupStorageError: String?
@EnvironmentObject private var automation: BackgroundSyncCoordinator
@EnvironmentObject private var licensing: LicensingManager
@Environment(\.openWindow) private var openWindow
@State private var selectedJob: SyncJob?
@State private var section: AppSection = .jobs
@@ -17,8 +18,11 @@ struct ContentView: View {
var body: some View {
ZStack {
mainContent
.opacity(showingSplash ? 0 : 1)
Group {
if licensing.canUseApp { mainContent }
else { LicenseRequiredView() }
}
.opacity(showingSplash ? 0 : 1)
if showingSplash {
SplashView()
@@ -38,6 +42,13 @@ struct ContentView: View {
try? await Task.sleep(for: .seconds(1.0))
withAnimation(.easeOut(duration: 0.28)) { showingSplash = false }
}
.task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(900))
if Task.isCancelled { return }
licensing.refresh()
}
}
}
private var mainContent: some View {
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"\>
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>RsyncStudioLicenseAPIBaseURL</key>
<string>https://rsync.vm/api/</string>
<key>RsyncStudioLicensePublicKey</key>
<string>8/rlCrS5mVEi4rGNXkw0Z22XkRpSH5+jPnD4f0GyV2k=</string>
</dict>
</plist>
+11 -1
View File
@@ -8,6 +8,7 @@ struct RsyncStudioApp: App {
let startupStorageError: String?
@StateObject private var runner: RsyncRunner
@StateObject private var automation: BackgroundSyncCoordinator
@StateObject private var licensing: LicensingManager
init() {
let schema = Schema([
@@ -16,6 +17,7 @@ struct RsyncStudioApp: App {
])
let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
KeychainStore.delete("defaultSSHPassword")
_licensing = StateObject(wrappedValue: LicensingManager())
do {
let container = try ModelContainer(for: schema, configurations: [configuration])
@@ -45,7 +47,14 @@ struct RsyncStudioApp: App {
.frame(minWidth: 1040, minHeight: 680)
.environmentObject(runner)
.environmentObject(automation)
.onAppear { automation.start() }
.environmentObject(licensing)
.onAppear {
licensing.refresh()
if licensing.canUseApp { automation.start() } else { automation.stop() }
}
.onChange(of: licensing.canUseApp) { _, allowed in
if allowed { automation.start() } else { automation.stop() }
}
}
.modelContainer(sharedModelContainer)
.commands {
@@ -60,6 +69,7 @@ struct RsyncStudioApp: App {
Settings {
SettingsView()
.environmentObject(automation)
.environmentObject(licensing)
}
}
}
@@ -0,0 +1,329 @@
import Combine
import Security
import Foundation
import CryptoKit
@MainActor
final class LicensingManager: ObservableObject {
enum State: Equatable {
case licensed(LicensePayload)
case trial(daysRemaining: Int, expiresAt: Date)
case expired
case invalid(String)
var canUseApp: Bool {
switch self {
case .licensed, .trial: true
case .expired, .invalid: false
}
}
}
struct LicensePayload: Codable, Equatable {
let licenseID: String
let customerName: String?
let customerEmail: String?
let edition: String
let installationID: String
let issuedAt: Date
let expiresAt: Date?
let maxMajorVersion: Int?
var displayOwner: String {
if let customerName, !customerName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return customerName }
if let customerEmail, !customerEmail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return customerEmail }
return "Licensed User"
}
}
struct ActivationResponse: Decodable {
let licenseToken: String
}
private struct ActivationRequest: Encodable {
let licenseKey: String
let installationID: String
let appVersion: String
let platform: String
}
private struct DeactivationRequest: Encodable {
let licenseToken: String
let installationID: String
}
enum LicensingError: LocalizedError {
case configuration(String)
case invalidLicense(String)
case server(String)
var errorDescription: String? {
switch self {
case .configuration(let message), .invalidLicense(let message), .server(let message): message
}
}
}
static let trialLengthDays = 30
@Published private(set) var state: State = .trial(daysRemaining: trialLengthDays, expiresAt: Date().addingTimeInterval(TimeInterval(trialLengthDays * 86_400)))
@Published private(set) var installationID = ""
@Published private(set) var isWorking = false
@Published var lastMessage: String?
private let calendar = Calendar(identifier: .gregorian)
private let firstLaunchAccount = "licensing.trial.firstLaunch"
private let lastSeenAccount = "licensing.trial.lastSeen"
private let installationAccount = "licensing.installationID"
private let tokenAccount = "licensing.token"
init() {
refresh()
}
var canUseApp: Bool { state.canUseApp }
var statusTitle: String {
switch state {
case .licensed: "Licensed"
case .trial: "Free Trial"
case .expired: "Trial Expired"
case .invalid: "Licence Problem"
}
}
var statusDetail: String {
switch state {
case .licensed(let payload):
let expiry = payload.expiresAt.map { " · expires \($0.formatted(date: .abbreviated, time: .omitted))" } ?? " · perpetual"
return "\(payload.edition) · \(payload.displayOwner)\(expiry)"
case .trial(let days, let expiresAt):
return "\(days) day\(days == 1 ? "" : "s") remaining · ends \(expiresAt.formatted(date: .abbreviated, time: .omitted))"
case .expired:
return "The 30-day evaluation period has ended. Activate a licence to continue using sync jobs."
case .invalid(let message):
return message
}
}
func refresh(now: Date = Date()) {
installationID = loadOrCreateInstallationID()
if let token = storedLicenseToken, !token.isEmpty {
do {
let payload = try verify(token: token, now: now)
state = .licensed(payload)
recordLastSeen(now)
return
} catch {
state = .invalid(error.localizedDescription)
recordLastSeen(now)
return
}
}
state = trialState(now: now)
}
func installSignedToken(_ token: String) throws {
let clean = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !clean.isEmpty else { throw LicensingError.invalidLicense("Paste a signed Rsync Studio licence token.") }
let payload = try verify(token: clean, now: Date())
guard KeychainStore.set(clean, for: tokenAccount) == errSecSuccess else {
throw LicensingError.invalidLicense("Rsync Studio could not save the licence securely in Keychain.")
}
state = .licensed(payload)
lastMessage = "Licence installed successfully."
}
func activate(licenseKey: String) async throws {
let key = licenseKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { throw LicensingError.invalidLicense("Enter your licence key.") }
guard let baseURL = configuredAPIBaseURL else {
throw LicensingError.configuration("Online activation is not configured in this build. A signed licence token can still be installed manually.")
}
isWorking = true
defer { isWorking = false }
let requestBody = ActivationRequest(
licenseKey: key,
installationID: installationID,
appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown",
platform: "macOS"
)
let response: ActivationResponse = try await post(path: "v1/activate", body: requestBody, baseURL: baseURL)
try installSignedToken(response.licenseToken)
lastMessage = "Rsync Studio has been activated on this Mac."
}
func deactivate() async throws {
guard let token = storedLicenseToken, !token.isEmpty else {
KeychainStore.delete(tokenAccount)
refresh()
return
}
isWorking = true
defer { isWorking = false }
if let baseURL = configuredAPIBaseURL {
let body = DeactivationRequest(licenseToken: token, installationID: installationID)
let _: EmptyResponse = try await post(path: "v1/deactivate", body: body, baseURL: baseURL)
}
guard KeychainStore.delete(tokenAccount) == errSecSuccess else {
throw LicensingError.invalidLicense("Rsync Studio could not remove the local licence from Keychain.")
}
refresh()
lastMessage = "Licence removed from this Mac."
}
func removeInvalidLocalLicence() {
KeychainStore.delete(tokenAccount)
refresh()
lastMessage = "Invalid local licence removed."
}
private var storedLicenseToken: String? {
let token = KeychainStore.string(for: tokenAccount)
return token.isEmpty ? nil : token
}
private var configuredPublicKey: String? {
guard let value = Bundle.main.infoDictionary?["RsyncStudioLicensePublicKey"] as? String else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private var configuredAPIBaseURL: URL? {
guard let value = Bundle.main.infoDictionary?["RsyncStudioLicenseAPIBaseURL"] as? String else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: trimmed), url.scheme?.lowercased() == "https" else { return nil }
return url
}
private func loadOrCreateInstallationID() -> String {
let existing = KeychainStore.string(for: installationAccount)
if UUID(uuidString: existing) != nil { return existing }
let value = UUID().uuidString.lowercased()
_ = KeychainStore.set(value, for: installationAccount)
return value
}
private func trialState(now: Date) -> State {
let firstLaunch: Date
let storedFirst = KeychainStore.string(for: firstLaunchAccount)
if let parsed = Self.parseDate(storedFirst) {
firstLaunch = parsed
} else {
firstLaunch = now
_ = KeychainStore.set(Self.formatDate(now), for: firstLaunchAccount)
}
if let lastSeen = Self.parseDate(KeychainStore.string(for: lastSeenAccount)), now < lastSeen.addingTimeInterval(-300) {
recordLastSeen(lastSeen)
return .invalid("The system clock appears to have moved backwards. Correct the date and time, then reopen Rsync Studio.")
}
recordLastSeen(now)
guard let expiresAt = calendar.date(byAdding: .day, value: Self.trialLengthDays, to: firstLaunch) else { return .expired }
if now >= expiresAt { return .expired }
let startOfToday = calendar.startOfDay(for: now)
let expiryDay = calendar.startOfDay(for: expiresAt)
let days = max(1, calendar.dateComponents([.day], from: startOfToday, to: expiryDay).day ?? 1)
return .trial(daysRemaining: days, expiresAt: expiresAt)
}
private func recordLastSeen(_ date: Date) {
let previous = Self.parseDate(KeychainStore.string(for: lastSeenAccount))
if previous == nil || date > previous! {
_ = KeychainStore.set(Self.formatDate(date), for: lastSeenAccount)
}
}
private func verify(token: String, now: Date) throws -> LicensePayload {
guard let publicKeyText = configuredPublicKey else {
throw LicensingError.configuration("Licence verification is not configured in this build. Add RsyncStudioLicensePublicKey to the signed app configuration.")
}
let parts = token.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 3, parts[0] == "RS3" else {
throw LicensingError.invalidLicense("The licence token format is invalid.")
}
guard let payloadData = Self.base64URLDecode(String(parts[1])),
let signature = Self.base64URLDecode(String(parts[2])),
let keyData = Data(base64Encoded: publicKeyText) ?? Self.base64URLDecode(publicKeyText) else {
throw LicensingError.invalidLicense("The licence token or embedded public key is not valid Base64.")
}
let publicKey: Curve25519.Signing.PublicKey
do { publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: keyData) }
catch { throw LicensingError.configuration("The embedded Rsync Studio licence public key is invalid.") }
guard publicKey.isValidSignature(signature, for: payloadData) else {
throw LicensingError.invalidLicense("The licence signature is invalid.")
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let payload: LicensePayload
do { payload = try decoder.decode(LicensePayload.self, from: payloadData) }
catch { throw LicensingError.invalidLicense("The signed licence payload is invalid.") }
guard payload.installationID.caseInsensitiveCompare(installationID) == .orderedSame else {
throw LicensingError.invalidLicense("This licence was activated for a different Rsync Studio installation.")
}
if let expiresAt = payload.expiresAt, now >= expiresAt {
throw LicensingError.invalidLicense("This licence expired on \(expiresAt.formatted(date: .abbreviated, time: .omitted)).")
}
if let maxMajor = payload.maxMajorVersion,
let currentMajor = Self.currentMajorVersion,
currentMajor > maxMajor {
throw LicensingError.invalidLicense("This licence covers Rsync Studio through major version \(maxMajor).")
}
return payload
}
private func post<Request: Encodable, Response: Decodable>(path: String, body: Request, baseURL: URL) async throws -> Response {
let url = baseURL.appending(path: path)
guard url.scheme?.lowercased() == "https" else { throw LicensingError.configuration("Licence API must use HTTPS.") }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = 20
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try JSONEncoder().encode(body)
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else { throw LicensingError.server("The licence server returned an invalid response.") }
guard (200..<300).contains(http.statusCode) else {
let serverMessage = (try? JSONDecoder().decode(ServerError.self, from: data).message) ?? "Activation server returned HTTP \(http.statusCode)."
throw LicensingError.server(serverMessage)
}
if Response.self == EmptyResponse.self, data.isEmpty {
return EmptyResponse() as! Response
}
do { return try JSONDecoder().decode(Response.self, from: data) }
catch { throw LicensingError.server("The licence server returned malformed data.") }
}
private struct ServerError: Decodable { let message: String }
private struct EmptyResponse: Codable { init() {} }
private static var currentMajorVersion: Int? {
guard let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { return nil }
return Int(version.split(separator: ".").first ?? "")
}
private static func formatDate(_ date: Date) -> String { ISO8601DateFormatter().string(from: date) }
private static func parseDate(_ value: String) -> Date? { value.isEmpty ? nil : ISO8601DateFormatter().date(from: value) }
private static func base64URLDecode(_ value: String) -> Data? {
var text = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
let remainder = text.count % 4
if remainder != 0 { text += String(repeating: "=", count: 4 - remainder) }
return Data(base64Encoded: text)
}
}
+18 -1
View File
@@ -10,6 +10,7 @@ struct SettingsView: View {
@Query(sort: \SyncJob.name) private var jobs: [SyncJob]
@Query(sort: \SyncRun.startedAt, order: .reverse) private var runs: [SyncRun]
@EnvironmentObject private var automation: BackgroundSyncCoordinator
@EnvironmentObject private var licensing: LicensingManager
@AppStorage("defaultSSHUsername") private var defaultSSHUsername = NSUserName()
@AppStorage("defaultSSHKeyPath") private var defaultSSHKeyPath = ""
@AppStorage("defaultSSHPort") private var defaultSSHPort = 22
@@ -120,6 +121,9 @@ struct SettingsView: View {
}
Section("Support & Updates") {
Button("Buy Rsync Studio…") { openCommercialURL(infoKey: "RsyncStudioPurchaseURL") }
Button("Manage Licence Online…") { openCommercialURL(infoKey: "RsyncStudioPortalURL") }
Button("Support…") { openCommercialURL(infoKey: "RsyncStudioSupportURL") }
Button("Export Diagnostics…") { exportDiagnostics() }
Text("Diagnostics contain app/macOS/rsync versions, redacted job configuration and recent failures. Private keys and credentials are never exported.")
.font(.caption)
@@ -139,8 +143,12 @@ struct SettingsView: View {
}
.formStyle(.grouped)
.tabItem { Label("General", systemImage: "gearshape") }
LicenseView()
.environmentObject(licensing)
.tabItem { Label("Licence", systemImage: "key") }
}
.frame(width: 650, height: 455)
.frame(width: 680, height: 500)
.onAppear {
KeychainStore.delete("defaultSSHPassword")
launchAtLogin = SMAppService.mainApp.status == .enabled
@@ -173,6 +181,15 @@ struct SettingsView: View {
}
}
private func openCommercialURL(infoKey: String) {
guard let raw = Bundle.main.object(forInfoDictionaryKey: infoKey) as? String,
let url = URL(string: raw), url.scheme == "https" else {
updateStatus = "This commercial service URL has not been configured for this build."
return
}
NSWorkspace.shared.open(url)
}
private func checkForUpdates() {
isCheckingUpdates = true
Task {
@@ -0,0 +1,36 @@
import SwiftUI
struct LicenseRequiredView: View {
@EnvironmentObject private var licensing: LicensingManager
@Environment(\.openSettings) private var openSettings
var body: some View {
VStack(spacing: 20) {
Image(systemName: "lock.shield.fill")
.font(.system(size: 54, weight: .medium))
.foregroundStyle(Color.accentColor)
VStack(spacing: 7) {
Text(licensing.statusTitle)
.font(.title2.bold())
Text(licensing.statusDetail)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
}
Button("Open Licence Settings") { openSettings() }
.buttonStyle(.borderedProminent)
.controlSize(.large)
Text("Your jobs and history remain on this Mac. Rsync Studio does not delete local data when a trial expires or a licence is removed.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
}
.padding(48)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor))
}
}
+133
View File
@@ -0,0 +1,133 @@
import SwiftUI
struct LicenseView: View {
@EnvironmentObject private var licensing: LicensingManager
@State private var licenceKey = ""
@State private var signedToken = ""
@State private var errorMessage: String?
@State private var showingTokenEntry = false
var body: some View {
Form {
Section("Licence Status") {
HStack(spacing: 12) {
Image(systemName: statusIcon)
.font(.title2)
.foregroundStyle(licensing.canUseApp ? Color.green : Color.orange)
VStack(alignment: .leading, spacing: 3) {
Text(licensing.statusTitle).font(.headline)
Text(licensing.statusDetail).font(.caption).foregroundStyle(.secondary)
}
Spacer()
}
LabeledContent("Installation ID", value: licensing.installationID)
.textSelection(.enabled)
Text("The installation ID is a random UUID stored in macOS Keychain. Rsync Studio does not use your Mac serial number or other hardware identifiers.")
.font(.caption)
.foregroundStyle(.secondary)
}
if case .licensed(let payload) = licensing.state {
Section("Current Licence") {
LabeledContent("Edition", value: payload.edition)
LabeledContent("Licence ID", value: payload.licenseID)
LabeledContent("Licensed to", value: payload.displayOwner)
if let expiresAt = payload.expiresAt {
LabeledContent("Expires", value: expiresAt.formatted(date: .long, time: .omitted))
} else {
LabeledContent("Term", value: "Perpetual")
}
Button("Deactivate This Mac…", role: .destructive) {
Task { await deactivate() }
}
.disabled(licensing.isWorking)
}
} else {
Section("Activate Rsync Studio") {
TextField("Licence key", text: $licenceKey, prompt: Text("XXXX-XXXX-XXXX-XXXX"))
.textContentType(.password)
HStack {
Button {
Task { await activate() }
} label: {
if licensing.isWorking { ProgressView().controlSize(.small) }
else { Label("Activate", systemImage: "checkmark.seal") }
}
.buttonStyle(.borderedProminent)
.disabled(licenceKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || licensing.isWorking)
Button(showingTokenEntry ? "Hide Signed Token" : "Install Signed Token…") {
showingTokenEntry.toggle()
}
}
Text("Online activation will be enabled when the licensing service URL is configured. Signed offline licence tokens can be installed without contacting a server.")
.font(.caption)
.foregroundStyle(.secondary)
}
if showingTokenEntry {
Section("Signed Offline Licence") {
TextEditor(text: $signedToken)
.font(.system(.caption, design: .monospaced))
.frame(minHeight: 86)
Button("Install Signed Licence") { installToken() }
.disabled(signedToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
if case .invalid = licensing.state {
Section("Recovery") {
Button("Remove Invalid Local Licence") { licensing.removeInvalidLocalLicence() }
Text("This removes only the stored local licence token. Trial dates and the installation identity are not reset.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
Section("Privacy") {
Text("Licence activation sends only the licence key, random installation ID, app version and platform to the licensing service. Sync paths, filenames, SSH hosts, credentials, job history and file contents are never included.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.alert("Rsync Studio Licence", isPresented: Binding(
get: { errorMessage != nil || licensing.lastMessage != nil },
set: { if !$0 { errorMessage = nil; licensing.lastMessage = nil } }
)) {
Button("OK", role: .cancel) { errorMessage = nil; licensing.lastMessage = nil }
} message: {
Text(errorMessage ?? licensing.lastMessage ?? "")
}
}
private var statusIcon: String {
switch licensing.state {
case .licensed: "checkmark.seal.fill"
case .trial: "clock.badge.checkmark"
case .expired: "clock.badge.exclamationmark"
case .invalid: "exclamationmark.shield.fill"
}
}
private func activate() async {
do {
try await licensing.activate(licenseKey: licenceKey)
licenceKey = ""
} catch { errorMessage = error.localizedDescription }
}
private func deactivate() async {
do { try await licensing.deactivate() }
catch { errorMessage = error.localizedDescription }
}
private func installToken() {
do {
try licensing.installSignedToken(signedToken)
signedToken = ""
showingTokenEntry = false
} catch { errorMessage = error.localizedDescription }
}
}