From ffa21f3b00352b4ff55a078e8f2cb2ade5996c32 Mon Sep 17 00:00:00 2001 From: ZOB2077 <56506480+ZOB2077@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:52:49 +0800 Subject: [PATCH] Fix isolated SwiftData storage and migration --- CHANGELOG.md | 15 ++ Gridnote.xcodeproj/project.pbxproj | 4 +- .../Model/GridnoteModelContainer.swift | 243 +++++++++++++++++- .../Persistence/PersistenceTests.swift | 69 +++++ RELEASE_NOTES.md | 13 +- 5 files changed, 331 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31479c4..b149e06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes are documented in this file. Gridnote follows [Semantic Versioning](https://semver.org/) for public releases where practical. +## [1.0.1] - 2026-07-16 + +### Fixed + +- Moved the production SwiftData database from the shared `Application Support/default.store` path to the app-specific `Application Support/com.gridnote.app/Gridnote.store` path. +- Added a one-time, read-only SQLite snapshot migration for compatible v1.0.0 stores, with schema and integrity validation before activation. +- Prevented unit-test host launches from opening or modifying the production database. +- Added regression coverage for production-path isolation, test-host isolation, directory creation, and legacy-book migration. + +### Upgrade Notes + +- Existing v1.0.0 data is migrated automatically when its legacy Gridnote schema remains readable. +- Migration failures stop initialization instead of silently replacing the library with a new empty store. + ## [1.0.0] - 2026-07-15 ### Release @@ -132,3 +146,4 @@ All notable changes are documented in this file. Gridnote follows [Semantic Vers [0.1.7]: https://github.com/ZOB2077/Gridnote/releases/tag/v0.1.7 [0.1.8]: https://github.com/ZOB2077/Gridnote/releases/tag/v0.1.8 [1.0.0]: https://github.com/ZOB2077/Gridnote/releases/tag/v1.0.0 +[1.0.1]: https://github.com/ZOB2077/Gridnote/releases/tag/v1.0.1 diff --git a/Gridnote.xcodeproj/project.pbxproj b/Gridnote.xcodeproj/project.pbxproj index cbcaf5a..7947f9a 100644 --- a/Gridnote.xcodeproj/project.pbxproj +++ b/Gridnote.xcodeproj/project.pbxproj @@ -319,8 +319,8 @@ /* Begin XCBuildConfiguration section */ B10000000000000000000001 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; MACOSX_DEPLOYMENT_TARGET = 26.0; SDKROOT = macosx; SWIFT_VERSION = 6.0; }; name = Debug; }; B10000000000000000000002 /* Release */ = {isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; MACOSX_DEPLOYMENT_TARGET = 26.0; SDKROOT = macosx; SWIFT_VERSION = 6.0; }; name = Release; }; - B10000000000000000000003 /* Gridnote Debug */ = {isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 10; ENABLE_TESTABILITY = YES; EXECUTABLE_NAME = DataHub; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Gridnote/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.gridnote.app; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; }; name = Debug; }; - B10000000000000000000004 /* Gridnote Release */ = {isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 10; ENABLE_TESTABILITY = YES; EXECUTABLE_NAME = DataHub; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Gridnote/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.gridnote.app; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; }; name = Release; }; + B10000000000000000000003 /* Gridnote Debug */ = {isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 11; ENABLE_TESTABILITY = YES; EXECUTABLE_NAME = DataHub; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Gridnote/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.gridnote.app; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; }; name = Debug; }; + B10000000000000000000004 /* Gridnote Release */ = {isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 11; ENABLE_TESTABILITY = YES; EXECUTABLE_NAME = DataHub; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Gridnote/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.gridnote.app; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; }; name = Release; }; B10000000000000000000005 /* GridnoteTests Debug */ = {isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.0; PRODUCT_BUNDLE_IDENTIFIER = com.gridnote.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SWIFT_VERSION = 6.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Gridnote.app/Contents/MacOS/DataHub"; }; name = Debug; }; B10000000000000000000006 /* GridnoteTests Release */ = {isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.0; PRODUCT_BUNDLE_IDENTIFIER = com.gridnote.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SWIFT_VERSION = 6.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Gridnote.app/Contents/MacOS/DataHub"; }; name = Release; }; B10000000000000000000007 /* GridnoteUITests Debug */ = {isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.0; PRODUCT_BUNDLE_IDENTIFIER = com.gridnote.uitests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SWIFT_VERSION = 6.0; TEST_TARGET_NAME = Gridnote; }; name = Debug; }; diff --git a/Gridnote/Persistence/Model/GridnoteModelContainer.swift b/Gridnote/Persistence/Model/GridnoteModelContainer.swift index 5c4cc0c..d8010ba 100644 --- a/Gridnote/Persistence/Model/GridnoteModelContainer.swift +++ b/Gridnote/Persistence/Model/GridnoteModelContainer.swift @@ -1,7 +1,11 @@ import Foundation +import SQLite3 import SwiftData enum GridnoteModelContainer { + private static let applicationSupportSubdirectory = "com.gridnote.app" + private static let storeFilename = "Gridnote.store" + static var schema: Schema { Schema([ BookRecord.self, @@ -15,18 +19,239 @@ enum GridnoteModelContainer { } static func make(inMemory: Bool = false) throws -> ModelContainer { - let configuration: ModelConfiguration - if let testStorePath = ProcessInfo.processInfo.environment["GRIDNOTE_TEST_STORE_PATH"] { - configuration = ModelConfiguration( - schema: schema, - url: URL(fileURLWithPath: testStorePath) - ) - } else { - configuration = ModelConfiguration( + let environment = ProcessInfo.processInfo.environment + if let testStorePath = environment["GRIDNOTE_TEST_STORE_PATH"] { + return try make(storeURL: URL(fileURLWithPath: testStorePath)) + } + + if shouldUseInMemoryStore(requested: inMemory, environment: environment) { + let configuration = ModelConfiguration( schema: schema, - isStoredInMemoryOnly: inMemory + isStoredInMemoryOnly: true ) + return try ModelContainer(for: schema, configurations: [configuration]) } + + let applicationSupportDirectory = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let storeURL = productionStoreURL(applicationSupportDirectory: applicationSupportDirectory) + try migrateLegacyStoreIfNeeded( + legacyStoreURL: applicationSupportDirectory.appendingPathComponent("default.store"), + destinationStoreURL: storeURL + ) + return try make(storeURL: storeURL) + } + + static func make(storeURL: URL) throws -> ModelContainer { + try FileManager.default.createDirectory( + at: storeURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let configuration = ModelConfiguration(schema: schema, url: storeURL) return try ModelContainer(for: schema, configurations: [configuration]) } + + static func productionStoreURL(applicationSupportDirectory: URL) -> URL { + applicationSupportDirectory + .appendingPathComponent(applicationSupportSubdirectory, isDirectory: true) + .appendingPathComponent(storeFilename, isDirectory: false) + } + + static func shouldUseInMemoryStore( + requested: Bool, + environment: [String: String] + ) -> Bool { + requested || environment["XCTestConfigurationFilePath"] != nil + } + + static func migrateLegacyStoreIfNeeded( + legacyStoreURL: URL, + destinationStoreURL: URL, + fileManager: FileManager = .default + ) throws { + guard !fileManager.fileExists(atPath: destinationStoreURL.path), + fileManager.fileExists(atPath: legacyStoreURL.path), + try sqliteStoreContainsGridnoteSchema(at: legacyStoreURL) else { + return + } + + try fileManager.createDirectory( + at: destinationStoreURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let temporaryURL = legacyStoreURL + .deletingLastPathComponent() + .appendingPathComponent("Gridnote-migration-\(UUID().uuidString).store") + defer { try? fileManager.removeItem(at: temporaryURL) } + + try backupSQLiteStore(from: legacyStoreURL, to: temporaryURL) + try fileManager.moveItem(at: temporaryURL, to: destinationStoreURL) + } + + private static func sqliteStoreContainsGridnoteSchema(at url: URL) throws -> Bool { + try withSQLiteDatabase(at: url, flags: SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX) { database in + try queryReturnsRow( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'ZBOOKRECORD' LIMIT 1", + database: database, + storeURL: url + ) + } + } + + private static func sqliteDatabasePassesIntegrityCheck( + _ database: OpaquePointer, + storeURL: URL + ) throws -> Bool { + var statement: OpaquePointer? + let prepareResult = sqlite3_prepare_v2(database, "PRAGMA integrity_check", -1, &statement, nil) + guard prepareResult == SQLITE_OK, let statement else { + throw sqliteError(database: database, code: prepareResult, storeURL: storeURL) + } + defer { sqlite3_finalize(statement) } + + guard sqlite3_step(statement) == SQLITE_ROW, + let result = sqlite3_column_text(statement, 0) else { + return false + } + return String(cString: result) == "ok" + } + + private static func queryReturnsRow( + _ sql: String, + database: OpaquePointer, + storeURL: URL + ) throws -> Bool { + var statement: OpaquePointer? + let prepareResult = sqlite3_prepare_v2(database, sql, -1, &statement, nil) + guard prepareResult == SQLITE_OK, let statement else { + throw sqliteError(database: database, code: prepareResult, storeURL: storeURL) + } + defer { sqlite3_finalize(statement) } + + let stepResult = sqlite3_step(statement) + switch stepResult { + case SQLITE_ROW: + return true + case SQLITE_DONE: + return false + default: + throw sqliteError(database: database, code: stepResult, storeURL: storeURL) + } + } + + private static func backupSQLiteStore(from sourceURL: URL, to destinationURL: URL) throws { + try withSQLiteDatabase( + at: sourceURL, + flags: SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX + ) { sourceDatabase in + try withInMemorySQLiteDatabase { destinationDatabase in + guard let backup = sqlite3_backup_init(destinationDatabase, "main", sourceDatabase, "main") else { + throw sqliteError( + database: destinationDatabase, + code: sqlite3_errcode(destinationDatabase), + storeURL: destinationURL + ) + } + + var busyRetries = 0 + var stepResult = SQLITE_OK + repeat { + stepResult = sqlite3_backup_step(backup, 128) + if stepResult == SQLITE_BUSY || stepResult == SQLITE_LOCKED { + busyRetries += 1 + guard busyRetries <= 100 else { break } + sqlite3_sleep(50) + } + } while stepResult == SQLITE_OK || stepResult == SQLITE_BUSY || stepResult == SQLITE_LOCKED + + let finishResult = sqlite3_backup_finish(backup) + guard stepResult == SQLITE_DONE, finishResult == SQLITE_OK else { + let errorCode = finishResult == SQLITE_OK ? stepResult : finishResult + throw sqliteError( + database: destinationDatabase, + code: errorCode, + storeURL: destinationURL + ) + } + + guard try queryReturnsRow( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'ZBOOKRECORD' LIMIT 1", + database: destinationDatabase, + storeURL: destinationURL + ), try sqliteDatabasePassesIntegrityCheck( + destinationDatabase, + storeURL: destinationURL + ) else { + throw storageError("The legacy Gridnote store could not be validated after migration.") + } + + var byteCount: sqlite3_int64 = 0 + guard let bytes = sqlite3_serialize(destinationDatabase, "main", &byteCount, 0), + byteCount > 0, + byteCount <= 512 * 1_024 * 1_024 else { + throw storageError("The migrated Gridnote store has an invalid size.") + } + defer { sqlite3_free(bytes) } + try Data(bytes: bytes, count: Int(byteCount)).write(to: destinationURL, options: .atomic) + } + } + } + + private static func withInMemorySQLiteDatabase( + operation: (OpaquePointer) throws -> T + ) throws -> T { + var database: OpaquePointer? + let openResult = sqlite3_open(":memory:", &database) + guard openResult == SQLITE_OK, let database else { + defer { if let database { sqlite3_close(database) } } + throw sqliteError( + database: database, + code: openResult, + storeURL: URL(fileURLWithPath: ":memory:") + ) + } + defer { sqlite3_close(database) } + return try operation(database) + } + + private static func withSQLiteDatabase( + at url: URL, + flags: Int32, + operation: (OpaquePointer) throws -> T + ) throws -> T { + var database: OpaquePointer? + let openResult = sqlite3_open_v2(url.path, &database, flags, nil) + guard openResult == SQLITE_OK, let database else { + defer { if let database { sqlite3_close(database) } } + throw sqliteError(database: database, code: openResult, storeURL: url) + } + defer { sqlite3_close(database) } + sqlite3_busy_timeout(database, 5_000) + return try operation(database) + } + + private static func sqliteError( + database: OpaquePointer?, + code: Int32, + storeURL: URL + ) -> Error { + let message = database.map { String(cString: sqlite3_errmsg($0)) } ?? "Unknown SQLite error" + let extendedCode = database.map(sqlite3_extended_errcode) ?? code + let systemError = database.map(sqlite3_system_errno) ?? 0 + return storageError( + "SQLite error \(code)/\(extendedCode), errno \(systemError) at \(storeURL.path): \(message)" + ) + } + + private static func storageError(_ message: String) -> Error { + NSError( + domain: "com.gridnote.app.storage", + code: 1, + userInfo: [NSLocalizedDescriptionKey: message] + ) + } } diff --git a/GridnoteTests/Persistence/PersistenceTests.swift b/GridnoteTests/Persistence/PersistenceTests.swift index 165e190..3c8237a 100644 --- a/GridnoteTests/Persistence/PersistenceTests.swift +++ b/GridnoteTests/Persistence/PersistenceTests.swift @@ -3,6 +3,75 @@ import XCTest @testable import Gridnote final class PersistenceTests: XCTestCase { + func testLegacyDefaultStoreMigratesIntoApplicationSpecificStore() throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent("gridnote-legacy-migration-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + let legacyStoreURL = rootURL.appendingPathComponent("default.store", isDirectory: false) + let destinationStoreURL = rootURL + .appendingPathComponent("com.gridnote.app", isDirectory: true) + .appendingPathComponent("Gridnote.store", isDirectory: false) + var expectedBookID: UUID? + + do { + let legacyContainer = try GridnoteModelContainer.make(storeURL: legacyStoreURL) + let repository = BookRepository(context: ModelContext(legacyContainer)) + let book = try repository.insert( + metadata: BookMetadata(title: "Legacy Book", sourceFilename: "legacy.epub"), + sourcePath: "/tmp/legacy.epub", + format: .epub + ) + expectedBookID = book.id + } + + try GridnoteModelContainer.migrateLegacyStoreIfNeeded( + legacyStoreURL: legacyStoreURL, + destinationStoreURL: destinationStoreURL + ) + + let migratedContainer = try GridnoteModelContainer.make(storeURL: destinationStoreURL) + let migratedBook = try BookRepository(context: ModelContext(migratedContainer)).fetch( + id: try XCTUnwrap(expectedBookID) + ) + XCTAssertEqual(migratedBook?.metadata.title, "Legacy Book") + } + + func testUnitTestHostUsesInMemoryStoreWithoutAnExplicitFlag() { + XCTAssertTrue( + GridnoteModelContainer.shouldUseInMemoryStore( + requested: false, + environment: ["XCTestConfigurationFilePath": "/tmp/Gridnote.xctestconfiguration"] + ) + ) + } + + func testProductionStoreURLIsIsolatedFromTheSharedSwiftDataDefault() { + let applicationSupportURL = URL(fileURLWithPath: "/tmp/Application Support", isDirectory: true) + + let storeURL = GridnoteModelContainer.productionStoreURL( + applicationSupportDirectory: applicationSupportURL + ) + + XCTAssertEqual( + storeURL, + applicationSupportURL + .appendingPathComponent("com.gridnote.app", isDirectory: true) + .appendingPathComponent("Gridnote.store", isDirectory: false) + ) + XCTAssertNotEqual(storeURL.lastPathComponent, "default.store") + } + + func testPersistentStoreCreatesItsApplicationSpecificDirectory() throws { + let directoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("gridnote-persistence-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directoryURL) } + let storeURL = directoryURL.appendingPathComponent("Gridnote.store", isDirectory: false) + + _ = try GridnoteModelContainer.make(storeURL: storeURL) + + XCTAssertTrue(FileManager.default.fileExists(atPath: directoryURL.path)) + } + func testBookAliasAndProgressCanBeCreatedAndQueried() throws { let container = try GridnoteModelContainer.make(inMemory: true) let context = ModelContext(container) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 85b4840..1af25f0 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,15 @@ -# Gridnote v1.0.0 +# Gridnote v1.0.1 -Gridnote v1.0.0 is the first stable personal-Mac release. It promotes the verified v0.1.8 application baseline without adding untested behavior. +Gridnote v1.0.1 is a data-safety update for the stable personal-Mac release. + +## 本次修复 + +- 将 SwiftData 数据库从系统共享的 `Application Support/default.store` 移至 Gridnote 专属目录。 +- 首次升级时通过只读 SQLite 快照自动迁移仍可读取的 v1.0.0 书库。 +- 迁移完成前校验 Gridnote 模型表和数据库完整性;迁移失败时停止初始化,不创建空书库掩盖错误。 +- 单元测试宿主强制使用内存数据库,不再接触用户的真实书库。 + +建议所有 v1.0.0 用户升级。此修复不会删除原始 TXT 或 EPUB 文件。 ## 功能预览