Something went wrong. Try again.
macOS menu bar app for Restic
Something went wrong. Try again.
5.8 kB · 146 lines
Swift
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147import Foundation
/// A restic repository. Credentials never live here — the password and any/// secret environment values are held in the Keychain and resolved at spawn time.public struct Repository: Codable, Sendable, Equatable { /// Passed to `restic --repo` verbatim, minus any basic-auth credentials. public var url: String /// `rest:` only. When set, the password is read from the Keychain and, with /// the username, handed to restic in its environment at launch. public var usesBasicAuth: Bool /// Basic-auth username. Not a secret, so it stays here where it can be /// seen and edited; only the matching password goes to the Keychain. public var basicAuthUsername: String /// `restic --host`. Snapshots are looked up by host, so this must be stable /// across machines that share a repository. public var host: String /// Declares that the backend rejects deletes. The engine then refuses every /// destructive operation regardless of what the UI asks for. public var appendOnly: Bool /// Freeform flags applied to every restic invocation. public var extraFlags: [ArgumentLine] /// Freeform, non-secret environment, e.g. `HTTPS_PROXY` or `RESTIC_CACHE_DIR`. public var extraEnvironment: [EnvironmentEntry]
public init( url: String, usesBasicAuth: Bool = false, basicAuthUsername: String = "", host: String = Repository.defaultHost, appendOnly: Bool = false, extraFlags: [ArgumentLine] = [], extraEnvironment: [EnvironmentEntry] = [] ) { self.url = url self.usesBasicAuth = usesBasicAuth self.basicAuthUsername = basicAuthUsername self.host = host self.appendOnly = appendOnly self.extraFlags = extraFlags self.extraEnvironment = extraEnvironment }
/// The local hostname with the Bonjour suffix removed, which is what restic /// would default to anyway. public static var defaultHost: String { var name = ProcessInfo.processInfo.hostName for suffix in [".local", ".lan"] where name.hasSuffix(suffix) { name.removeLast(suffix.count) } return name }
public var backend: RepositoryBackend? { RepositoryBackend.detect(url) }}
/// A name/value pair added to restic's environment.////// Plain text, and stored as such in `config.json`. Snapshot deliberately has no/// "secret" variant: the only credentials v1 needs are the repository password/// and rest: basic auth, both of which have dedicated Keychain entries. A/// half-used secret mechanism would be worse than none, because it would look/// like somewhere safe to put a key.public struct EnvironmentEntry: Codable, Sendable, Equatable, Identifiable { public var id: UUID public var name: String public var value: String
public init(id: UUID = UUID(), name: String, value: String = "") { self.id = id self.name = name self.value = value }
public var isValid: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty }}
/// The backends v1 supports.////// `sftp:` is deliberately absent: restic implements it by shelling out to `ssh`,/// which can block on a host-key or passphrase prompt that a menu bar app has no/// way to answer, turning a backup into an indefinite hang rather than an error./// Object stores would only need environment variables and are cheap to add later.public enum RepositoryBackend: String, Codable, Sendable, CaseIterable { case rest case local
public static let supportedDescription = "a rest: URL or a local path"
public static func detect(_ url: String) -> RepositoryBackend? { let trimmed = url.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return nil } if trimmed.hasPrefix("rest:") { return .rest } if trimmed.hasPrefix("local:") { return .local } // A bare path is restic's local-repository syntax. if trimmed.hasPrefix("/") || trimmed.hasPrefix("~") || trimmed.hasPrefix(".") { return .local } return nil }
/// Whether a run should wait for `NWPathMonitor` to report a usable path. /// A repository on an external disk must not be blocked while offline. public var requiresNetwork: Bool { switch self { case .rest: true case .local: false } }}
public enum RepositoryValidationError: Error, Equatable, CustomStringConvertible { case empty case unsupportedScheme(String)
public var description: String { switch self { case .empty: "Enter a repository location." case .unsupportedScheme(let scheme): """ Snapshot doesn't support \(scheme) repositories — it supports \ \(RepositoryBackend.supportedDescription). \(Self.sftpNote(for: scheme)) """ } }
private static func sftpNote(for scheme: String) -> String { scheme == "sftp:" ? "sftp needs ssh, which can block on a key passphrase or host-key prompt " + "that a background app can't answer." : "" }}
extension Repository { /// Rejects unsupported backends up front rather than letting a run hang later. public static func validate(url: String) -> RepositoryValidationError? { let trimmed = url.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return .empty } guard RepositoryBackend.detect(trimmed) == nil else { return nil } let scheme = trimmed.prefix { $0 != ":" } return .unsupportedScheme(trimmed.contains(":") ? "\(scheme):" : "that kind of") }}