diff --git a/.gitignore b/.gitignore index b7364b1..eaf5b70 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ dist/ # Logs *.log /tmp/wcm*.log + +# Local screenshot previews of the showcase site (not part of this branch) +site/.preview/ diff --git a/README.md b/README.md index 7575560..9e6022a 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,9 @@ their signed-in sessions). helper app, persists across reboots) - 🔄 **Auto-detects WeChat updates** and offers to refresh stale clones, preserving each clone's signed-in session +- 💾 **Automatic settings snapshots** — rotating backups of your slot names, + order, and preferences (taken on launch and before any import); roll back + from **Preferences → Restore from Snapshot…** if something goes wrong - 📋 **Lists running instances** with their PIDs and start times - 🪟 **Bring any instance to the front**, quit individual instances, or quit all - 🔍 **Auto-detects WeChat** in `/Applications`; falls back to a file picker diff --git a/Sources/WeChatMulti/AppDelegate.swift b/Sources/WeChatMulti/AppDelegate.swift index 2753da6..fd9031b 100644 --- a/Sources/WeChatMulti/AppDelegate.swift +++ b/Sources/WeChatMulti/AppDelegate.swift @@ -169,6 +169,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in self?.appState.runHealthCheck() } + // Take a throttled settings snapshot off the main thread shortly after + // launch (deduped + rate-limited inside the launcher). + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 3.0) { [weak self] in + self?.launcher.captureSettingsSnapshotIfDue() + } Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { [weak self] _ in self?.appState.runHealthCheck() } diff --git a/Sources/WeChatMulti/DirectorySnapshotStore.swift b/Sources/WeChatMulti/DirectorySnapshotStore.swift new file mode 100644 index 0000000..120b94a --- /dev/null +++ b/Sources/WeChatMulti/DirectorySnapshotStore.swift @@ -0,0 +1,46 @@ +import Foundation +import WeChatMultiCore + +/// Filesystem-backed `SnapshotStore`: each snapshot is a JSON file in +/// `~/Library/Application Support/WeChat Multi/Snapshots/`. The app is not +/// sandboxed, so Application Support is the natural home (survives clone +/// resets, which only touch `~/Applications/WeChat Multi/` and the containers). +final class DirectorySnapshotStore: SnapshotStore { + let directory: URL + + init(directory: URL? = nil) { + if let directory { + self.directory = directory + } else { + let base = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support", isDirectory: true) + self.directory = base.appendingPathComponent("WeChat Multi/Snapshots", isDirectory: true) + } + } + + private func ensureDirectory() throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + func writeSnapshot(_ data: Data, id: String) throws { + try ensureDirectory() + try data.write(to: directory.appendingPathComponent(id), options: .atomic) + } + + func readSnapshot(id: String) throws -> Data { + try Data(contentsOf: directory.appendingPathComponent(id)) + } + + func snapshotIDs() -> [String] { + (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? [] + } + + func removeSnapshot(id: String) throws { + let url = directory.appendingPathComponent(id) + // Already-gone counts as success (keeps prune idempotent). + guard FileManager.default.fileExists(atPath: url.path) else { return } + try FileManager.default.removeItem(at: url) + } +} diff --git a/Sources/WeChatMulti/PreferencesView.swift b/Sources/WeChatMulti/PreferencesView.swift index 477eea2..9bb10fc 100644 --- a/Sources/WeChatMulti/PreferencesView.swift +++ b/Sources/WeChatMulti/PreferencesView.swift @@ -1,5 +1,6 @@ import SwiftUI import AppKit +import WeChatMultiCore /// Preferences window — the "crafted Mac utility" surface. Hero card at the /// top establishes brand and orientation; grouped settings cards below carry @@ -323,12 +324,19 @@ struct PreferencesView: View { .font(.system(size: 11)) .foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) + + Text("WeChat Multi also keeps automatic snapshots of these settings (on launch and before any import), so a mistake is recoverable.") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) } HStack(spacing: 8) { Button("Export Settings…") { exportSettings() } .help("Save your slot names, order, and preferences to a JSON file") Button("Import Settings…") { importSettings() } .help("Load a previously-exported JSON file. Asks for confirmation before overwriting current preferences.") + Button("Restore from Snapshot…") { restoreFromSnapshot() } + .help("Roll your settings back to an automatic snapshot taken on launch or before an import.") Spacer() } .controlSize(.small) @@ -512,4 +520,57 @@ struct PreferencesView: View { alert.runModal() } } + + private func restoreFromSnapshot() { + let snapshots = launcher.settingsSnapshots() + guard !snapshots.isEmpty else { + let alert = NSAlert() + alert.messageText = "No Snapshots Yet" + alert.informativeText = """ + WeChat Multi hasn't taken any settings snapshots yet. They're \ + created automatically on launch and just before an import. + """ + alert.addButton(withTitle: "OK") + alert.runModal() + return + } + + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + + let chooser = NSAlert() + chooser.messageText = "Restore from Snapshot" + chooser.informativeText = """ + Pick a snapshot to roll your slot names, display order, WeChat.app path, \ + and onboarding state back to. Clone bundles and signed-in sessions aren't \ + affected. + """ + chooser.alertStyle = .warning + + let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 280, height: 25)) + for snapshot in snapshots { + popup.addItem(withTitle: formatter.string(from: snapshot.createdAt)) + } + chooser.accessoryView = popup + chooser.addButton(withTitle: "Restore") + chooser.addButton(withTitle: "Cancel") + + guard chooser.runModal() == .alertFirstButtonReturn else { return } + let index = popup.indexOfSelectedItem + guard index >= 0, index < snapshots.count else { return } + let chosen = snapshots[index] + + do { + try launcher.restoreSettingsSnapshot(id: chosen.id) + refresh() + } catch { + let alert = NSAlert() + alert.messageText = "Restore Failed" + alert.informativeText = error.localizedDescription + alert.alertStyle = .warning + alert.addButton(withTitle: "OK") + alert.runModal() + } + } } diff --git a/Sources/WeChatMulti/WeChatLauncher.swift b/Sources/WeChatMulti/WeChatLauncher.swift index a772164..f606b11 100644 --- a/Sources/WeChatMulti/WeChatLauncher.swift +++ b/Sources/WeChatMulti/WeChatLauncher.swift @@ -16,6 +16,7 @@ final class WeChatLauncher { private let store: KeyValueStore private let slots: SlotSettings + private let snapshots: SnapshotStore private let defaultPaths = [ "/Applications/WeChat.app", @@ -26,13 +27,20 @@ final class WeChatLauncher { /// Directory that holds the cloned bundles. let cloneRoot: URL - init(store: KeyValueStore = UserDefaults.standard, cloneRoot: URL? = nil) { + init(store: KeyValueStore = UserDefaults.standard, + cloneRoot: URL? = nil, + snapshotStore: SnapshotStore? = nil) { self.store = store self.slots = SlotSettings(store: store) + self.snapshots = snapshotStore ?? DirectorySnapshotStore() self.cloneRoot = cloneRoot ?? FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Applications/WeChat Multi", isDirectory: true) } + private var appVersion: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?" + } + var wechatAppPath: String? { if let custom = store.string(forKey: DefaultsKey.customWeChatPath), FileManager.default.fileExists(atPath: custom) { @@ -425,14 +433,35 @@ final class WeChatLauncher { // MARK: - Backup / restore (delegated to tested SettingsBackup) func exportSettingsData() throws -> Data { - let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?" - return try SettingsBackup.exportData(store: store, appVersion: appVersion) + try SettingsBackup.exportData(store: store, appVersion: appVersion) } func importSettings(from data: Data) throws { + // Snapshot the current state first so a regretted import is recoverable. + try? SettingsSnapshots.capture(from: store, appVersion: appVersion, into: snapshots) try SettingsBackup.restore(from: data, to: store) } + // MARK: - Automatic settings snapshots (rotating, tested SettingsSnapshots) + + /// Take a throttled snapshot of the current settings. Safe to call on every + /// launch — it dedups identical state and rate-limits to one per interval. + /// Failures are swallowed: a backup that can't be written must never block + /// the app from starting. + func captureSettingsSnapshotIfDue() { + try? SettingsSnapshots.captureIfDue(from: store, appVersion: appVersion, into: snapshots) + } + + /// Available settings snapshots, newest first. + func settingsSnapshots() -> [SnapshotInfo] { + SettingsSnapshots.list(in: snapshots) + } + + /// Roll the live settings back to a chosen snapshot. + func restoreSettingsSnapshot(id: String) throws { + try SettingsSnapshots.restore(id: id, from: snapshots, to: store) + } + // MARK: - Process helpers @discardableResult diff --git a/Sources/WeChatMultiCore/SettingsBackup.swift b/Sources/WeChatMultiCore/SettingsBackup.swift index 157fdd1..1ad0010 100644 --- a/Sources/WeChatMultiCore/SettingsBackup.swift +++ b/Sources/WeChatMultiCore/SettingsBackup.swift @@ -42,10 +42,21 @@ public enum SettingsBackup { } } + // MARK: - Content comparison + + /// True when two payloads carry the same *user settings*, ignoring volatile + /// metadata (`exportedAt`, `appVersion`, schema `version`). The snapshot + /// engine uses this to avoid writing back-to-back duplicates. + public static func sameSettings(_ a: Payload, _ b: Payload) -> Bool { + a.slotNames == b.slotNames && + a.slotOrder == b.slotOrder && + a.wechatAppPath == b.wechatAppPath && + a.didShowOnboarding == b.didShowOnboarding + } + // MARK: - Export - public static func makePayload(store: KeyValueStore, appVersion: String, now: Date) -> Payload { - let names = (store.dictionary(forKey: DefaultsKey.slotNames) as? [String: String]) ?? [:] + public static func makePayload(store: KeyValueStore, appVersion: String, now: Date) -> Payload { let names = (store.dictionary(forKey: DefaultsKey.slotNames) as? [String: String]) ?? [:] let order = (store.array(forKey: DefaultsKey.slotDisplayOrder) as? [Int]) ?? [] return Payload( version: Payload.currentVersion, diff --git a/Sources/WeChatMultiCore/SettingsSnapshots.swift b/Sources/WeChatMultiCore/SettingsSnapshots.swift new file mode 100644 index 0000000..b561d1a --- /dev/null +++ b/Sources/WeChatMultiCore/SettingsSnapshots.swift @@ -0,0 +1,167 @@ +import Foundation + +/// Filesystem seam for settings snapshots — mirrors the `KeyValueStore` pattern +/// so the snapshot *policy* (when to write, what to prune, how to restore) is +/// pure and unit-testable against an in-memory fake, with no disk access. +/// +/// `id` is an opaque snapshot identifier; the directory-backed implementation +/// in the app uses it as the on-disk filename. +public protocol SnapshotStore { + func writeSnapshot(_ data: Data, id: String) throws + func readSnapshot(id: String) throws -> Data + func snapshotIDs() -> [String] // unordered + func removeSnapshot(id: String) throws +} + +/// One snapshot's listing entry: its id plus the timestamp parsed from it. +public struct SnapshotInfo: Equatable { + public let id: String + public let createdAt: Date + + public init(id: String, createdAt: Date) { + self.id = id + self.createdAt = createdAt + } +} + +/// What `capture` decided to do — surfaced so callers (and tests) can tell a +/// real write apart from a deliberate no-op. +public enum SnapshotOutcome: Equatable { + case wrote(id: String) + case skippedUnchanged // settings identical to the most recent snapshot + case skippedThrottled // changed, but too soon since the last snapshot +} + +/// Automatic, rotating snapshots of the user's settings, layered on top of the +/// tested `SettingsBackup` codec. The app takes one on launch (throttled) and +/// one right before a risky import, so a bad import or accidental change is +/// recoverable from the most recent good state. +public enum SettingsSnapshots { + public static let filePrefix = "wechatmulti-settings-" + public static let fileExtension = "json" + + /// How many snapshots to keep before pruning the oldest. + public static let defaultMaxSnapshots = 12 + /// Minimum spacing between periodic snapshots (6 hours). + public static let defaultMinInterval: TimeInterval = 6 * 60 * 60 + + // MARK: - ID ⇄ Date + + // UTC, fixed-width, lexicographically sortable in timestamp order. + private static let stampFormat = "yyyyMMdd'T'HHmmss'Z'" + + private static func stampFormatter() -> DateFormatter { + // DateFormatter isn't thread-safe; build a fresh one per call. These + // operations are rare (launch / import), so the cost is irrelevant. + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(identifier: "UTC") + f.dateFormat = stampFormat + return f + } + + /// Filename for a snapshot taken at `date`, e.g. + /// `wechatmulti-settings-20260606T110000Z.json`. + public static func makeID(at date: Date) -> String { + "\(filePrefix)\(stampFormatter().string(from: date)).\(fileExtension)" + } + + /// Parse the timestamp back out of a snapshot id, or nil if it doesn't match + /// the expected `prefix + stamp + .json` shape. + public static func date(fromID id: String) -> Date? { + let suffix = ".\(fileExtension)" + guard id.hasPrefix(filePrefix), id.hasSuffix(suffix) else { return nil } + let start = id.index(id.startIndex, offsetBy: filePrefix.count) + let end = id.index(id.endIndex, offsetBy: -suffix.count) + guard start < end else { return nil } + return stampFormatter().date(from: String(id[start.. [SnapshotInfo] { + store.snapshotIDs() + .compactMap { id -> SnapshotInfo? in + guard let date = date(fromID: id) else { return nil } + return SnapshotInfo(id: id, createdAt: date) + } + .sorted { lhs, rhs in + lhs.createdAt != rhs.createdAt ? lhs.createdAt > rhs.createdAt : lhs.id > rhs.id + } + } + + // MARK: - Capture + + /// Write a snapshot of the current settings, then prune to `maxSnapshots`. + /// + /// - Skips with `.skippedUnchanged` when the meaningful settings are + /// identical to the most recent snapshot (no churn of duplicates). + /// - When `minInterval` is non-nil, skips with `.skippedThrottled` if the + /// last snapshot is newer than that — used for the periodic path. Pass + /// `nil` (the default) to force a write, e.g. just before an import. + @discardableResult + public static func capture(from kv: KeyValueStore, + appVersion: String, + now: Date = Date(), + into store: SnapshotStore, + maxSnapshots: Int = defaultMaxSnapshots, + minInterval: TimeInterval? = nil) throws -> SnapshotOutcome { + let newPayload = SettingsBackup.makePayload(store: kv, appVersion: appVersion, now: now) + + if let latest = list(in: store).first { + if let data = try? store.readSnapshot(id: latest.id), + let latestPayload = try? SettingsBackup.decode(data), + SettingsBackup.sameSettings(latestPayload, newPayload) { + return .skippedUnchanged + } + if let minInterval, now.timeIntervalSince(latest.createdAt) < minInterval { + return .skippedThrottled + } + } + + let id = makeID(at: now) + let data = try SettingsBackup.exportData(store: kv, appVersion: appVersion, now: now) + try store.writeSnapshot(data, id: id) + try prune(in: store, keeping: max(1, maxSnapshots)) + return .wrote(id: id) + } + + /// Throttled + deduped convenience for the periodic (launch / timer) path. + @discardableResult + public static func captureIfDue(from kv: KeyValueStore, + appVersion: String, + now: Date = Date(), + into store: SnapshotStore, + maxSnapshots: Int = defaultMaxSnapshots, + minInterval: TimeInterval = defaultMinInterval) throws -> SnapshotOutcome { + try capture(from: kv, appVersion: appVersion, now: now, into: store, + maxSnapshots: maxSnapshots, minInterval: minInterval) + } + + // MARK: - Prune + + /// Keep the newest `keeping` snapshots; remove the rest. A snapshot that's + /// already gone is treated as success by the store, so this is idempotent. + public static func prune(in store: SnapshotStore, keeping maxSnapshots: Int) throws { + guard maxSnapshots >= 0 else { return } + let all = list(in: store) + guard all.count > maxSnapshots else { return } + for stale in all[maxSnapshots...] { + try store.removeSnapshot(id: stale.id) + } + } + + // MARK: - Restore + + /// Apply a chosen snapshot back onto the live settings store, reusing + /// `SettingsBackup`'s validation + stale-path sanitization. + public static func restore(id: String, + from store: SnapshotStore, + to kv: KeyValueStore, + pathExists: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) }) throws { + let data = try store.readSnapshot(id: id) + try SettingsBackup.restore(from: data, to: kv, pathExists: pathExists) + } +} diff --git a/Tests/WeChatMultiCoreTests/SettingsSnapshotsTests.swift b/Tests/WeChatMultiCoreTests/SettingsSnapshotsTests.swift new file mode 100644 index 0000000..a7f7111 --- /dev/null +++ b/Tests/WeChatMultiCoreTests/SettingsSnapshotsTests.swift @@ -0,0 +1,167 @@ +import XCTest +@testable import WeChatMultiCore + +/// In-memory `SnapshotStore` for deterministic tests — no disk access. +final class InMemorySnapshotStore: SnapshotStore { + var files: [String: Data] = [:] + + func writeSnapshot(_ data: Data, id: String) throws { files[id] = data } + + func readSnapshot(id: String) throws -> Data { + guard let data = files[id] else { + throw NSError(domain: "InMemorySnapshotStore", code: 404) + } + return data + } + + func snapshotIDs() -> [String] { Array(files.keys) } + + func removeSnapshot(id: String) throws { files.removeValue(forKey: id) } +} + +final class SettingsSnapshotsTests: XCTestCase { + + private let t0 = Date(timeIntervalSince1970: 1_700_000_000) + private let appVersion = "2.0.0" + + private func store(names: [String: String] = ["1": "Work"], + order: [Int] = [1]) -> InMemoryKeyValueStore { + let kv = InMemoryKeyValueStore() + kv.set(names, forKey: DefaultsKey.slotNames) + kv.set(order, forKey: DefaultsKey.slotDisplayOrder) + return kv + } + + // MARK: - ID ⇄ Date + + func testMakeIDAndParseRoundTrip() { + let id = SettingsSnapshots.makeID(at: t0) + XCTAssertTrue(id.hasPrefix("wechatmulti-settings-")) + XCTAssertTrue(id.hasSuffix(".json")) + let parsed = SettingsSnapshots.date(fromID: id) + XCTAssertNotNil(parsed) + // makeID is second-resolution; t0 is on an integer second. + XCTAssertEqual(parsed!.timeIntervalSince1970, t0.timeIntervalSince1970, accuracy: 1) + } + + func testParseRejectsJunkIDs() { + XCTAssertNil(SettingsSnapshots.date(fromID: "garbage")) + XCTAssertNil(SettingsSnapshots.date(fromID: "wechatmulti-settings-notadate.json")) + XCTAssertNil(SettingsSnapshots.date(fromID: "other-prefix-20260101T000000Z.json")) + } + + // MARK: - Capture + + func testCaptureWritesWhenEmpty() throws { + let kv = store() + let snaps = InMemorySnapshotStore() + let outcome = try SettingsSnapshots.capture(from: kv, appVersion: appVersion, now: t0, into: snaps) + guard case .wrote = outcome else { return XCTFail("expected .wrote, got \(outcome)") } + XCTAssertEqual(snaps.files.count, 1) + } + + func testCaptureDedupsUnchangedSettings() throws { + let kv = store() + let snaps = InMemorySnapshotStore() + _ = try SettingsSnapshots.capture(from: kv, appVersion: appVersion, now: t0, into: snaps) + // Same settings, much later, no throttle → still a no-op. + let outcome = try SettingsSnapshots.capture( + from: kv, appVersion: appVersion, now: t0.addingTimeInterval(100_000), into: snaps) + XCTAssertEqual(outcome, .skippedUnchanged) + XCTAssertEqual(snaps.files.count, 1) + } + + func testCaptureIfDueThrottlesRecentChanges() throws { + let kv = store() + let snaps = InMemorySnapshotStore() + _ = try SettingsSnapshots.capture(from: kv, appVersion: appVersion, now: t0, into: snaps) + // Change the settings, but only 60s later with a 1h interval. + kv.set(["1": "Work", "2": "Personal"], forKey: DefaultsKey.slotNames) + let outcome = try SettingsSnapshots.captureIfDue( + from: kv, appVersion: appVersion, now: t0.addingTimeInterval(60), + into: snaps, minInterval: 3600) + XCTAssertEqual(outcome, .skippedThrottled) + XCTAssertEqual(snaps.files.count, 1) + } + + func testCaptureIfDueWritesAfterInterval() throws { + let kv = store() + let snaps = InMemorySnapshotStore() + _ = try SettingsSnapshots.capture(from: kv, appVersion: appVersion, now: t0, into: snaps) + kv.set(["1": "Work", "2": "Personal"], forKey: DefaultsKey.slotNames) + let outcome = try SettingsSnapshots.captureIfDue( + from: kv, appVersion: appVersion, now: t0.addingTimeInterval(7200), + into: snaps, minInterval: 3600) + guard case .wrote = outcome else { return XCTFail("expected .wrote, got \(outcome)") } + XCTAssertEqual(snaps.files.count, 2) + } + + // MARK: - Listing & pruning + + func testListIsNewestFirst() throws { + let kv = store() + let snaps = InMemorySnapshotStore() + for i in 0..<3 { + kv.set(["1": "Name\(i)"], forKey: DefaultsKey.slotNames) + _ = try SettingsSnapshots.capture( + from: kv, appVersion: appVersion, now: t0.addingTimeInterval(Double(i) * 3600), into: snaps) + } + let list = SettingsSnapshots.list(in: snaps) + XCTAssertEqual(list.count, 3) + XCTAssertEqual(list.first?.createdAt, t0.addingTimeInterval(2 * 3600)) + XCTAssertEqual(list.last?.createdAt, t0) + } + + func testCapturePrunesToMaxSnapshots() throws { + let kv = store() + let snaps = InMemorySnapshotStore() + for i in 0..<20 { + kv.set(["1": "Name\(i)"], forKey: DefaultsKey.slotNames) // change each time + _ = try SettingsSnapshots.capture( + from: kv, appVersion: appVersion, now: t0.addingTimeInterval(Double(i) * 3600), + into: snaps, maxSnapshots: 5) + } + XCTAssertEqual(snaps.files.count, 5) + // The five kept are the newest (i = 15…19). + let list = SettingsSnapshots.list(in: snaps) + XCTAssertEqual(list.first?.createdAt, t0.addingTimeInterval(19 * 3600)) + XCTAssertEqual(list.last?.createdAt, t0.addingTimeInterval(15 * 3600)) + } + + func testListIgnoresUnparseableFiles() throws { + let kv = store() + let snaps = InMemorySnapshotStore() + _ = try SettingsSnapshots.capture(from: kv, appVersion: appVersion, now: t0, into: snaps) + snaps.files["not-a-snapshot.txt"] = Data("hi".utf8) + XCTAssertEqual(SettingsSnapshots.list(in: snaps).count, 1) + } + + // MARK: - Restore (the headline: recover from a mistake) + + func testRestoreRecoversPreviousState() throws { + let kv = store(names: ["1": "Work"], order: [1]) + kv.set("/Applications/WeChat.app", forKey: DefaultsKey.customWeChatPath) + let snaps = InMemorySnapshotStore() + + let outcome = try SettingsSnapshots.capture(from: kv, appVersion: appVersion, now: t0, into: snaps) + guard case let .wrote(id) = outcome else { return XCTFail("expected .wrote") } + + // Disaster: a bad import wipes the settings. + kv.set([String: String](), forKey: DefaultsKey.slotNames) + kv.removeObject(forKey: DefaultsKey.slotDisplayOrder) + + // Roll back from the snapshot. + try SettingsSnapshots.restore(id: id, from: snaps, to: kv, pathExists: { _ in true }) + + XCTAssertEqual(kv.dictionary(forKey: DefaultsKey.slotNames) as? [String: String], ["1": "Work"]) + XCTAssertEqual(kv.array(forKey: DefaultsKey.slotDisplayOrder) as? [Int], [1]) + XCTAssertEqual(kv.string(forKey: DefaultsKey.customWeChatPath), "/Applications/WeChat.app") + } + + func testRestoreFromMissingIDThrows() { + let snaps = InMemorySnapshotStore() + XCTAssertThrowsError( + try SettingsSnapshots.restore(id: "wechatmulti-settings-20260101T000000Z.json", + from: snaps, to: InMemoryKeyValueStore())) + } +}