From 0b657910ab4e6ae223b40fcc3c793161a1c3b782 Mon Sep 17 00:00:00 2001 From: iuhoay Date: Mon, 15 Jun 2026 22:48:28 +0800 Subject: [PATCH] fix: store history in an app-namespaced SwiftData store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A non-sandboxed app's bare ModelContainer(for:) resolves to the shared ~/Library/Application Support/default.store. Any other non-sandboxed app using the same default writes to that file too; when one opens it with a different schema, Core Data recreates the store and silently drops our HistoryItem table — the user's whole history vanishes. Pin the store to Application Support//Lumo.store so it can't collide with another app (and keeps Debug "Lumo Dev" history separate from Release). On first launch with the new path, importLegacyHistory(from:into:) does a one-time, non-destructive rescue of any history still in the old shared store: it confirms via read-only Core Data metadata that the file holds our HistoryItem entity before opening it read-only, so another app's data is never touched. Add LegacyStoreRescueTests covering the matching-store import, the foreign-schema skip, and the missing-file no-op. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 9 +++ mac-app/Lumo/Services/HistoryStore.swift | 92 ++++++++++++++++++++- mac-app/LumoTests/HistoryStoreTests.swift | 98 +++++++++++++++++++++++ 3 files changed, 198 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bc188f..4cd13cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to Lumo are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Translation history could disappear: the database was stored at a shared + system location that any other app could overwrite. History now lives in + Lumo's own folder, and any history still present from the old location is + carried over automatically on first launch after updating. + ## [0.2.0] - 2026-06-15 ### Added diff --git a/mac-app/Lumo/Services/HistoryStore.swift b/mac-app/Lumo/Services/HistoryStore.swift index 48f2800..19c6ec1 100644 --- a/mac-app/Lumo/Services/HistoryStore.swift +++ b/mac-app/Lumo/Services/HistoryStore.swift @@ -1,8 +1,18 @@ +import CoreData import Foundation import SwiftData /// Owns the SwiftData container so non-View code (AppModel) can persist history, /// while the History window injects the same container for `@Query`. +/// +/// The store is pinned to a per-app URL +/// (`Application Support//Lumo.store`). The SwiftData default for a +/// non-sandboxed app is the *shared* `Application Support/default.store`, which +/// every other non-sandboxed app using a bare `ModelContainer` also writes to. +/// When one of them opens that file with a different schema, Core Data recreates +/// the store and silently drops our `HistoryItem` table — i.e. the user's whole +/// history vanishes. A bundle-id-scoped path can't collide with another app, and +/// also keeps the Debug ("Lumo Dev") and Release stores apart. @MainActor final class HistoryStore { static let shared = HistoryStore() @@ -11,12 +21,92 @@ final class HistoryStore { private init() { do { - container = try ModelContainer(for: HistoryItem.self) + let storeURL = Self.storeURL() + try FileManager.default.createDirectory( + at: storeURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let isNewStore = !FileManager.default.fileExists(atPath: storeURL.path) + + container = try ModelContainer( + for: HistoryItem.self, + configurations: ModelConfiguration(url: storeURL) + ) + + // Carry forward any history still living in the old shared store the + // first time the dedicated store is created, so existing installs + // don't see an empty list after updating. + if isNewStore { + Self.importLegacyHistory(from: Self.legacySharedStoreURL, into: container.mainContext) + } } catch { fatalError("Failed to create SwiftData container: \(error)") } } + /// Dedicated, per-app on-disk location for the history database. + static func storeURL() -> URL { + let appID = Bundle.main.bundleIdentifier ?? "com.iuhoay.lumo" + return URL.applicationSupportDirectory + .appending(path: appID, directoryHint: .isDirectory) + .appending(path: "Lumo.store") + } + + /// The legacy SwiftData default for a non-sandboxed app — the shared file we + /// migrated away from. + static let legacySharedStoreURL = URL.applicationSupportDirectory.appending(path: "default.store") + + /// One-time, best-effort import of history from `legacyURL` into `context`. + /// + /// Deliberately non-destructive: the legacy file is opened read-only and + /// never deleted, because the shared `default.store` may belong to a + /// *different* non-sandboxed app. We first confirm via the store's Core Data + /// metadata that it actually holds our `HistoryItem` entity — if it holds + /// another app's schema, we touch nothing. Any failure is swallowed: the + /// rescue is a courtesy, not a correctness requirement. + @discardableResult + static func importLegacyHistory(from legacyURL: URL, into context: ModelContext) -> Int { + guard FileManager.default.fileExists(atPath: legacyURL.path) else { return 0 } + + // Read-only metadata probe — does NOT migrate or mutate the store. + guard + let metadata = try? NSPersistentStoreCoordinator.metadataForPersistentStore( + ofType: NSSQLiteStoreType, at: legacyURL + ), + let entityHashes = metadata[NSStoreModelVersionHashesKey] as? [String: Any], + entityHashes.keys.contains("HistoryItem") + else { return 0 } + + guard + let legacy = try? ModelContainer( + for: HistoryItem.self, + configurations: ModelConfiguration(url: legacyURL, allowsSave: false) + ) + else { return 0 } + + let legacyContext = ModelContext(legacy) + guard + let items = try? legacyContext.fetch(FetchDescriptor()), + !items.isEmpty + else { return 0 } + + for item in items { + context.insert( + HistoryItem( + createdAt: item.createdAt, + mode: item.mode, + sourceText: item.sourceText, + outputText: item.outputText, + target: item.target, + provider: item.provider, + model: item.model + ) + ) + } + try? context.save() + return items.count + } + func add(_ item: HistoryItem) { container.mainContext.insert(item) try? container.mainContext.save() diff --git a/mac-app/LumoTests/HistoryStoreTests.swift b/mac-app/LumoTests/HistoryStoreTests.swift index cd5ebfb..9bc860a 100644 --- a/mac-app/LumoTests/HistoryStoreTests.swift +++ b/mac-app/LumoTests/HistoryStoreTests.swift @@ -185,3 +185,101 @@ struct HistoryStoreTests { #expect(stored.provider == .openAI) } } + +/// A throwaway @Model standing in for *another* non-sandboxed app's SwiftData +/// store, used to prove the rescue refuses to import from a foreign schema. +@Model +final class ForeignRecord { + var label: String + init(label: String) { + self.label = label + } +} + +@Suite("Legacy shared-store rescue") +struct LegacyStoreRescueTests { + @MainActor + private func tempStoreURL() -> URL { + FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + .appending(path: "default.store") + } + + /// Returns the container (not just its context): a `ModelContext` does not + /// retain its `ModelContainer`, so the caller must hold the container for the + /// test's lifetime or the context dangles. + @MainActor + private func inMemoryContainer() throws -> ModelContainer { + try ModelContainer( + for: HistoryItem.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + } + + /// A file-backed HistoryItem store (the shape the real legacy store has) is + /// imported row-for-row into the fresh store. + @Test @MainActor + func importsHistoryFromMatchingLegacyStore() throws { + let legacyURL = tempStoreURL() + try FileManager.default.createDirectory( + at: legacyURL.deletingLastPathComponent(), withIntermediateDirectories: true + ) + + // Seed the legacy store on disk. + let legacy = try ModelContainer( + for: HistoryItem.self, + configurations: ModelConfiguration(url: legacyURL) + ) + for i in 0 ..< 2 { + legacy.mainContext.insert( + HistoryItem( + mode: .translate, sourceText: "src-\(i)", outputText: "out-\(i)", + target: "English", provider: .openAI, model: "m" + ) + ) + } + try legacy.mainContext.save() + + let destination = try inMemoryContainer() + let imported = HistoryStore.importLegacyHistory(from: legacyURL, into: destination.mainContext) + + #expect(imported == 2) + let fetched = try destination.mainContext.fetch(FetchDescriptor()) + #expect(Set(fetched.map(\.sourceText)) == ["src-0", "src-1"]) + + // Non-destructive: the legacy file is left in place. + #expect(FileManager.default.fileExists(atPath: legacyURL.path)) + } + + /// A store owned by a *different* app (different entity) must be left + /// untouched — nothing imported, no crash. + @Test @MainActor + func skipsForeignSchemaStore() throws { + let legacyURL = tempStoreURL() + try FileManager.default.createDirectory( + at: legacyURL.deletingLastPathComponent(), withIntermediateDirectories: true + ) + + let foreign = try ModelContainer( + for: ForeignRecord.self, + configurations: ModelConfiguration(url: legacyURL) + ) + foreign.mainContext.insert(ForeignRecord(label: "not ours")) + try foreign.mainContext.save() + + let destination = try inMemoryContainer() + let imported = HistoryStore.importLegacyHistory(from: legacyURL, into: destination.mainContext) + + #expect(imported == 0) + #expect(try destination.mainContext.fetch(FetchDescriptor()).isEmpty) + } + + /// No legacy file at all is a clean no-op. + @Test @MainActor + func noOpWhenLegacyStoreMissing() throws { + let destination = try inMemoryContainer() + let imported = HistoryStore.importLegacyHistory(from: tempStoreURL(), into: destination.mainContext) + #expect(imported == 0) + #expect(try destination.mainContext.fetch(FetchDescriptor()).isEmpty) + } +}