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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 91 additions & 1 deletion mac-app/Lumo/Services/HistoryStore.swift
Original file line number Diff line number Diff line change
@@ -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/<bundle-id>/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()
Expand All @@ -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<HistoryItem>()),
!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()
Expand Down
98 changes: 98 additions & 0 deletions mac-app/LumoTests/HistoryStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<HistoryItem>())
#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<HistoryItem>()).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<HistoryItem>()).isEmpty)
}
}
Loading