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
19 changes: 19 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,25 @@ The form's commit path lives on `WineForm` (`makeWine`, `apply(to:)`,
`makeNotes`) rather than inside `ReviewView.save()`, so it is reachable from
tests. It was inlined once, and a mutation dropping `.trimmed` went undetected.

## The cellar is scoped to the account

Each account gets its own store file, `Application Support/cellar-<token>.store`,
where the token is SHA256 of Apple's user identifier truncated to 16 hex chars —
hashed because an account identifier has no business sitting in a filename that
appears in backups and crash reports. `CellarStore` owns this, along with the
quarantine/recovery path that used to be private inside the `App` struct.

Isolation is by construction, not by filtering: a signed-in account cannot reach
another's bottles because the container it holds is not attached to their file.
The alternative — one store with an owner column and a predicate on every query —
is one forgotten filter away from showing someone else's cellar.

Identity is scoped the same way (`com.vinnota.displayName.<token>`,
`avatar-<token>.jpg`). Signing out ends the session and keeps the account's data;
`forgetThisAccount()` is the destructive one. That matters because Apple supplies
`fullName` and `email` exactly once per Apple ID, so deleting them on sign-out
made a returning user permanently nameless.

## What is real, not simulated

The design fakes its scanner (a 1.6s delay and canned text) and its
Expand Down
132 changes: 132 additions & 0 deletions Vinnota/Model/CellarStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import CryptoKit
import Foundation
import Observation
import SwiftData

/// The cellar on disk, scoped to one account.
///
/// Every account gets its own store file. Isolation is by construction — a
/// signed-in account cannot reach another's bottles because the container it
/// holds is not attached to their file at all. The alternative, one shared
/// store filtered by an owner column, is one forgotten predicate away from
/// showing someone else's cellar; this codebase already has a guard that was
/// only skin deep (`canDelete`), so the weaker design was not worth the risk.
///
/// Signing out does not delete anything. The file stays exactly where it is and
/// is picked up again when that account signs back in.
@Observable
@MainActor
final class CellarStore {
/// The container the view tree is currently bound to.
private(set) var container: ModelContainer
/// Set when the store had to be reset or could not be opened at all.
private(set) var failure: String?
/// Changes whenever the container does, so the view tree can be rebuilt and
/// every `@Query` re-run against the new store rather than serving rows it
/// already fetched from the previous account's.
private(set) var identity: String

private static let schema = Schema([Wine.self, TastingNote.self])

/// Opens with no account attached: a signed-out app shows an empty cellar,
/// not the last account's.
init() {
let opened = Self.open(for: nil)
container = opened.container
failure = opened.failure
identity = Self.token(for: nil)
}

/// Points the app at `accountID`'s cellar, or at an empty one when signed
/// out. A no-op when already on that account, so an unrelated auth change
/// does not tear down the view tree.
func open(for accountID: String?) {
let wanted = Self.token(for: accountID)
guard wanted != identity else { return }
let opened = Self.open(for: accountID)
container = opened.container
failure = opened.failure
identity = wanted
}

// MARK: - Naming

/// A stable, filesystem-safe, non-reversible name for an account's store.
///
/// Apple's user identifier is hashed rather than used directly: it is an
/// account identifier, and it has no business sitting in a filename that
/// shows up in backups, crash reports and file listings.
static func token(for accountID: String?) -> String {
guard let accountID, !accountID.isEmpty else { return "signed-out" }
let digest = SHA256.hash(data: Data(accountID.utf8))
return digest.map { String(format: "%02x", $0) }.joined().prefix(16).description
}

static func storeURL(for accountID: String) -> URL? {
guard let dir = try? FileManager.default.url(for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true) else { return nil }
return dir.appendingPathComponent("cellar-\(token(for: accountID)).store")
}

// MARK: - Opening

private static func open(for accountID: String?) -> (container: ModelContainer, failure: String?) {
// No account, or nowhere to write: keep everything in memory. Signed
// out there is nothing to show, and nothing entered can leak to disk.
guard let accountID, let url = storeURL(for: accountID) else {
return (inMemory(), nil)
}

let config = ModelConfiguration(schema: schema, url: url)

if let opened = try? ModelContainer(for: schema, configurations: [config]) {
return (opened, nil)
}

// A store that will not open must not brick the app permanently. The
// damaged file is moved aside rather than deleted, so nothing is
// destroyed and it can still be recovered by hand.
quarantineStore(at: url)

if let recovered = try? ModelContainer(for: schema, configurations: [config]) {
return (recovered, "The cellar could not be opened and has been reset. "
+ "The previous file was kept alongside it.")
}

return (inMemory(), "The cellar cannot be saved on this device right now. "
+ "Anything added this session will not be kept.")
}

private static func inMemory() -> ModelContainer {
// Force-try is deliberate: an in-memory store has no disk to fail on,
// and there is no remaining fallback if the schema itself is invalid.
try! ModelContainer(
for: schema,
configurations: [ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)]
)
}

/// Renames the store and its SQLite sidecars out of the way.
static func quarantineStore(at url: URL) {
let stamp = ISO8601DateFormatter().string(from: Date())
.replacingOccurrences(of: ":", with: "-")
for suffix in ["", "-shm", "-wal"] {
let from = URL(fileURLWithPath: url.path + suffix)
guard FileManager.default.fileExists(atPath: from.path) else { continue }
let to = URL(fileURLWithPath: url.path + ".damaged-" + stamp + suffix)
try? FileManager.default.moveItem(at: from, to: to)
}
}
}

extension AuthController.State {
/// The account whose cellar should be open, or nil when signed out.
var accountID: String? {
switch self {
case .signedOut: return nil
case .signedIn(let userID, _): return userID
}
}
}
80 changes: 61 additions & 19 deletions Vinnota/Services/AuthController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,18 @@ final class AuthController: NSObject {
/// Apple returns `fullName` and `email` only on the FIRST authorization for
/// a given Apple ID — every later sign-in leaves them nil. They are stored
/// here at that one opportunity, or the account is nameless forever after.
private static let nameKey = "com.vinnota.displayName"
private static let emailKey = "com.vinnota.email"
/// Identity is stored per account. A second Apple ID on the same device
/// must not see the first one's name, email or picture, and the store is
/// scoped the same way — see `CellarStore`.
private static func nameKey(_ token: String) -> String { "com.vinnota.displayName.\(token)" }
private static func emailKey(_ token: String) -> String { "com.vinnota.email.\(token)" }

var displayName: String? { UserDefaults.standard.string(forKey: Self.nameKey) }
var email: String? { UserDefaults.standard.string(forKey: Self.emailKey) }
/// The account whose identity is currently readable. Signed out, this is
/// the "signed-out" bucket, which nothing ever writes to.
private var accountToken: String { CellarStore.token(for: state.accountID) }

var displayName: String? { UserDefaults.standard.string(forKey: Self.nameKey(accountToken)) }
var email: String? { UserDefaults.standard.string(forKey: Self.emailKey(accountToken)) }

/// A profile picture the user chose.
///
Expand All @@ -49,15 +56,20 @@ final class AuthController: NSObject {
/// own, stored on this device beside the session and cleared with it.
private(set) var avatar: UIImage?

private static var avatarURL: URL? {
private static func avatarURL(_ token: String) -> URL? {
try? FileManager.default.url(for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil, create: true)
.appendingPathComponent("avatar.jpg")
.appendingPathComponent("avatar-\(token).jpg")
}

/// Only ever loads the signed-in account's picture. Signed out there is no
/// account to load one for, so nothing is read — which is also why this
/// must not be called before the session has been established.
func loadAvatar() {
guard let url = Self.avatarURL, let data = try? Data(contentsOf: url) else {
guard case .signedIn = state,
let url = Self.avatarURL(accountToken),
let data = try? Data(contentsOf: url) else {
avatar = nil
return
}
Expand All @@ -70,13 +82,14 @@ final class AuthController: NSObject {
guard let image = UIImage(data: data),
let scaled = Self.downscale(image, to: 512),
let jpeg = scaled.jpegData(compressionQuality: 0.85),
let url = Self.avatarURL else { return }
case .signedIn = state,
let url = Self.avatarURL(accountToken) else { return }
try? jpeg.write(to: url, options: .atomic)
avatar = scaled
}

func clearAvatar() {
if let url = Self.avatarURL { try? FileManager.default.removeItem(at: url) }
if let url = Self.avatarURL(accountToken) { try? FileManager.default.removeItem(at: url) }
avatar = nil
}

Expand Down Expand Up @@ -104,9 +117,15 @@ final class AuthController: NSObject {

/// Re-establishes the session on launch. Apple can revoke a credential out
/// of band, so a stored ID is verified before it is trusted.
/// The picture is loaded only after the session has been established, and
/// only for the account that owns it. Loading it first meant a rejected
/// session had already decoded the previous user's photo into memory.
func restore() async {
loadAvatar()
guard let stored = Self.readKeychain() else { return }
guard let stored = Self.readKeychain() else {
state = .signedOut
loadAvatar()
return
}

if stored == Self.stubUserID {
#if DEBUG
Expand All @@ -116,6 +135,7 @@ final class AuthController: NSObject {
Self.deleteKeychain()
state = .signedOut
#endif
loadAvatar()
return
}
let provider = ASAuthorizationAppleIDProvider()
Expand All @@ -127,29 +147,51 @@ final class AuthController: NSObject {
Self.deleteKeychain()
state = .signedOut
}
loadAvatar()
}

/// Ends the session without destroying the account's data.
///
/// The name, email, picture and cellar stay on disk under this account's
/// own keys, so signing back in restores them — which matters because Apple
/// hands over `fullName` and `email` exactly once, and deleting them made a
/// returning user permanently nameless. Another account signing in reads a
/// different set of keys and a different store file, so nothing here is
/// visible to them.
func signOut() {
Self.deleteKeychain()
// The name and email are dropped with the session. Apple will not hand
// them over again on a later sign-in, so a returning user shows as
// nameless — that is Apple's behaviour, not a bug here.
UserDefaults.standard.removeObject(forKey: Self.nameKey)
UserDefaults.standard.removeObject(forKey: Self.emailKey)
clearAvatar()
avatar = nil
state = .signedOut
}

/// Erases everything belonging to the signed-in account: the picture, the
/// stored identity and the cellar file. Separate from `signOut` because
/// signing out and deleting your data are different intentions.
func forgetThisAccount() {
guard let accountID = state.accountID else { return }
let token = CellarStore.token(for: accountID)
if let url = Self.avatarURL(token) { try? FileManager.default.removeItem(at: url) }
UserDefaults.standard.removeObject(forKey: Self.nameKey(token))
UserDefaults.standard.removeObject(forKey: Self.emailKey(token))
if let store = CellarStore.storeURL(for: accountID) {
for suffix in ["", "-shm", "-wal"] {
try? FileManager.default.removeItem(atPath: store.path + suffix)
}
}
signOut()
}

/// Captures whatever Apple chose to share, at the only moment it is offered.
private static func storeIdentity(from credential: ASAuthorizationAppleIDCredential) {
let token = CellarStore.token(for: credential.user)
if let name = credential.fullName {
let formatter = PersonNameComponentsFormatter()
formatter.style = .default
let formatted = formatter.string(from: name).trimmingCharacters(in: .whitespaces)
if !formatted.isEmpty { UserDefaults.standard.set(formatted, forKey: nameKey) }
if !formatted.isEmpty { UserDefaults.standard.set(formatted, forKey: nameKey(token)) }
}
if let email = credential.email, !email.isEmpty {
UserDefaults.standard.set(email, forKey: emailKey)
UserDefaults.standard.set(email, forKey: emailKey(token))
}
}

Expand Down
61 changes: 12 additions & 49 deletions Vinnota/VinnotaApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,64 +4,27 @@ import SwiftUI
@main
struct VinnotaApp: App {
@State private var auth = AuthController()

/// The book starts empty — a new account has scanned nothing yet.
///
/// A store that will not open must not brick the app permanently: the
/// on-disk file is moved aside once and a fresh store is opened in its
/// place. The damaged file is kept rather than deleted, so nothing is
/// destroyed and it can still be recovered by hand.
private let container: ModelContainer
private let storeFailure: String?
/// The cellar is opened per account — see `CellarStore`. It starts
/// detached, so a launch that has not yet resolved a session shows nothing.
@State private var store = CellarStore()

init() {
Typo.registerFonts()

let schema = Schema([Wine.self, TastingNote.self])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)

if let opened = try? ModelContainer(for: schema, configurations: [config]) {
container = opened
storeFailure = nil
return
}

Self.quarantineStore(at: config.url)

if let recovered = try? ModelContainer(for: schema, configurations: [config]) {
container = recovered
storeFailure = "The cellar could not be opened and has been reset. "
+ "The previous file was kept alongside it."
return
}

// Disk is unusable. An in-memory store keeps the app running for this
// session instead of crash-looping on every launch.
container = try! ModelContainer(
for: schema,
configurations: [ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)]
)
storeFailure = "The cellar cannot be saved on this device right now. "
+ "Anything added this session will not be kept."
}

/// Renames the store and its SQLite sidecars out of the way.
private static func quarantineStore(at url: URL) {
let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-")
for suffix in ["", "-shm", "-wal"] {
let from = URL(fileURLWithPath: url.path + suffix)
guard FileManager.default.fileExists(atPath: from.path) else { continue }
let to = URL(fileURLWithPath: url.path + ".damaged-" + stamp + suffix)
try? FileManager.default.moveItem(at: from, to: to)
}
}

var body: some Scene {
WindowGroup {
RootView(storeFailure: storeFailure)
RootView(storeFailure: store.failure)
.environment(auth)
.modelContainer(store.container)
// Rebuilds the tree when the account changes, so every `@Query`
// re-runs against the new store rather than serving rows it
// already fetched from the previous account's.
.id(store.identity)
.task { await auth.restore() }
.onChange(of: auth.state) { _, new in
store.open(for: new.accountID)
}
}
.modelContainer(container)
}
}
Loading