Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ dist/
# Logs
*.log
/tmp/wcm*.log

# Local screenshot previews of the showcase site (not part of this branch)
site/.preview/
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions Sources/WeChatMulti/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
46 changes: 46 additions & 0 deletions Sources/WeChatMulti/DirectorySnapshotStore.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
61 changes: 61 additions & 0 deletions Sources/WeChatMulti/PreferencesView.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
}
}
}
35 changes: 32 additions & 3 deletions Sources/WeChatMulti/WeChatLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions Sources/WeChatMultiCore/SettingsBackup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading