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
188 changes: 187 additions & 1 deletion PotassiumProviderCore/ProviderEventStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,76 @@ public struct KDriveProviderActivityEvent: Identifiable, Codable, Equatable, Sen
}
}

public enum KDriveProviderTimelineFilter: String, CaseIterable, Codable, Equatable, Sendable {
case errorsAndConflicts
case allActivity
}

public enum KDriveProviderTimelineEntry: Identifiable, Equatable, Sendable {
case conflict(KDriveConflictEvent)
case activity(KDriveProviderActivityEvent)

public var id: String {
switch self {
case .conflict(let event):
return "conflict-\(event.id.uuidString)"
case .activity(let event):
return "activity-\(event.id.uuidString)"
}
}

public var date: Date {
switch self {
case .conflict(let event):
return event.resolvedAt ?? event.detectedAt
case .activity(let event):
return event.occurredAt
}
}

public var cursor: KDriveProviderTimelineCursor {
switch self {
case .conflict(let event):
return KDriveProviderTimelineCursor(date: date, kind: .conflict, eventID: event.id)
case .activity(let event):
return KDriveProviderTimelineCursor(date: date, kind: .activity, eventID: event.id)
}
}
}

public struct KDriveProviderTimelineCursor: Codable, Equatable, Sendable {
public enum Kind: Int, Codable, Equatable, Sendable {
case activity = 0
case conflict = 1
}

public let date: Date
public let kind: Kind
public let eventID: UUID

public init(date: Date, kind: Kind, eventID: UUID) {
self.date = date
self.kind = kind
self.eventID = eventID
}
}

public struct KDriveProviderTimelinePage: Equatable, Sendable {
public let entries: [KDriveProviderTimelineEntry]
public let nextCursor: KDriveProviderTimelineCursor?
public let hasMore: Bool

public init(
entries: [KDriveProviderTimelineEntry],
nextCursor: KDriveProviderTimelineCursor?,
hasMore: Bool
) {
self.entries = entries
self.nextCursor = nextCursor
self.hasMore = hasMore
}
}

public protocol KDriveProviderEventStoring: Sendable {
func saveConflict(_ event: KDriveConflictEvent) async throws
func recordActivity(_ event: KDriveProviderActivityEvent) async throws
Expand All @@ -233,6 +303,14 @@ public protocol KDriveProviderEventStoring: Sendable {
func removeEvents(domainIdentifier: String) async throws
}

public protocol KDriveProviderEventTimelinePaging: Sendable {
func timelinePage(
filter: KDriveProviderTimelineFilter,
before cursor: KDriveProviderTimelineCursor?,
limit: Int
) async throws -> KDriveProviderTimelinePage
}

public struct KDriveProviderEventDomainStatistics: Equatable, Sendable {
public let domainIdentifier: String
public let unresolvedConflictCount: Int
Expand Down Expand Up @@ -283,7 +361,7 @@ public protocol KDriveProviderEventPruning: Sendable {
func pruneActivityEvents(maximumCount: Int) async throws
}

public actor KDriveProviderEventSQLiteStore: KDriveProviderEventStoring, KDriveProviderEventStatisticsProviding, KDriveProviderEventObserving, KDriveProviderEventPruning, KDriveProviderEventExporting {
public actor KDriveProviderEventSQLiteStore: KDriveProviderEventStoring, KDriveProviderEventTimelinePaging, KDriveProviderEventStatisticsProviding, KDriveProviderEventObserving, KDriveProviderEventPruning, KDriveProviderEventExporting {
private let database: Connection
private let maximumActivityEventCount: Int
private var eventChangeContinuations: [UUID: AsyncStream<Void>.Continuation] = [:]
Expand Down Expand Up @@ -361,6 +439,73 @@ public actor KDriveProviderEventSQLiteStore: KDriveProviderEventStoring, KDriveP
.map(Self.activityEvent(from:))
}

public func timelinePage(
filter: KDriveProviderTimelineFilter,
before cursor: KDriveProviderTimelineCursor?,
limit: Int
) throws -> KDriveProviderTimelinePage {
let pageSize = max(0, limit)
guard pageSize > 0 else {
return KDriveProviderTimelinePage(entries: [], nextCursor: nil, hasMore: false)
}

let queryLimit = pageSize + 1
var conflictQuery = ProviderEventSchema.conflictEvents
var activityQuery = ProviderEventSchema.activityEvents
.filter(ProviderEventSchema.relatedConflictID == nil)

if filter == .errorsAndConflicts {
activityQuery = activityQuery.filter(
ProviderEventSchema.outcome == KDriveProviderActivityOutcome.failure.rawValue
)
}

if let cursor {
let cursorDate = cursor.date.timeIntervalSince1970
let cursorID = cursor.eventID.uuidString
conflictQuery = conflictQuery.filter(Self.timelineCursorPredicate(
date: ProviderEventSchema.effectiveConflictDate,
id: ProviderEventSchema.id,
kind: .conflict,
beforeDate: cursorDate,
beforeKind: cursor.kind,
beforeID: cursorID
))
activityQuery = activityQuery.filter(Self.timelineCursorPredicate(
date: ProviderEventSchema.occurredAt,
id: ProviderEventSchema.id,
kind: .activity,
beforeDate: cursorDate,
beforeKind: cursor.kind,
beforeID: cursorID
))
}

let conflicts = try database.prepare(
conflictQuery
.order(ProviderEventSchema.effectiveConflictDate.desc, ProviderEventSchema.id.desc)
.limit(queryLimit)
).map(Self.conflictEvent(from:))
let activity = try database.prepare(
activityQuery
.order(ProviderEventSchema.occurredAt.desc, ProviderEventSchema.id.desc)
.limit(queryLimit)
).map(Self.activityEvent(from:))

let merged = (
conflicts.map(KDriveProviderTimelineEntry.conflict)
+ activity.map(KDriveProviderTimelineEntry.activity)
).sorted(by: Self.isTimelineEntryNewer)
let entries = Array(merged.prefix(pageSize))
let hasMore = merged.count > pageSize

return KDriveProviderTimelinePage(
entries: entries,
nextCursor: hasMore ? entries.last?.cursor : nil,
hasMore: hasMore
)
}

public func eventStatistics(domainIdentifiers: Set<String>) throws -> [KDriveProviderEventDomainStatistics] {
let requestedDomainIdentifiers = Set(domainIdentifiers.filter { $0.isEmpty == false })
guard requestedDomainIdentifiers.isEmpty == false else { return [] }
Expand Down Expand Up @@ -531,11 +676,14 @@ public actor KDriveProviderEventSQLiteStore: KDriveProviderEventStoring, KDriveP

try database.execute("CREATE INDEX IF NOT EXISTS conflict_events_domainIdentifier_idx ON conflict_events(domainIdentifier)")
try database.execute("CREATE INDEX IF NOT EXISTS conflict_events_detectedAt_idx ON conflict_events(detectedAt)")
try database.execute("CREATE INDEX IF NOT EXISTS conflict_events_timeline_idx ON conflict_events(COALESCE(resolvedAt, detectedAt) DESC, id DESC)")
try database.execute("CREATE INDEX IF NOT EXISTS provider_activity_events_domainIdentifier_idx ON provider_activity_events(domainIdentifier)")
try database.execute("CREATE INDEX IF NOT EXISTS provider_activity_events_occurredAt_idx ON provider_activity_events(occurredAt)")
try database.execute("CREATE INDEX IF NOT EXISTS provider_activity_events_relatedConflictID_idx ON provider_activity_events(relatedConflictID)")
try database.execute("CREATE INDEX IF NOT EXISTS provider_activity_events_outcome_idx ON provider_activity_events(outcome)")
try database.execute("CREATE INDEX IF NOT EXISTS provider_activity_events_correlationID_idx ON provider_activity_events(correlationID)")
try database.execute("CREATE INDEX IF NOT EXISTS provider_activity_events_timeline_idx ON provider_activity_events(occurredAt DESC, id DESC) WHERE relatedConflictID IS NULL")
try database.execute("CREATE INDEX IF NOT EXISTS provider_activity_events_failure_timeline_idx ON provider_activity_events(outcome, occurredAt DESC, id DESC) WHERE relatedConflictID IS NULL")
}

private static func migrateActivityEvents(on database: Connection) throws {
Expand Down Expand Up @@ -731,6 +879,41 @@ public actor KDriveProviderEventSQLiteStore: KDriveProviderEventStoring, KDriveP
return rhs
}

private static func timelineCursorPredicate(
date: SQLite.Expression<Double>,
id: SQLite.Expression<String>,
kind: KDriveProviderTimelineCursor.Kind,
beforeDate: Double,
beforeKind: KDriveProviderTimelineCursor.Kind,
beforeID: String
) -> SQLite.Expression<Bool> {
let earlierDate = date < beforeDate
let sameDate = date == beforeDate

if kind.rawValue < beforeKind.rawValue {
return earlierDate || sameDate
}
if kind.rawValue > beforeKind.rawValue {
return earlierDate
}
return earlierDate || (sameDate && id < beforeID)
}

private static func isTimelineEntryNewer(
_ lhs: KDriveProviderTimelineEntry,
than rhs: KDriveProviderTimelineEntry
) -> Bool {
let lhsCursor = lhs.cursor
let rhsCursor = rhs.cursor
if lhsCursor.date != rhsCursor.date {
return lhsCursor.date > rhsCursor.date
}
if lhsCursor.kind != rhsCursor.kind {
return lhsCursor.kind.rawValue > rhsCursor.kind.rawValue
}
return lhsCursor.eventID.uuidString > rhsCursor.eventID.uuidString
}

private static func setters(for event: KDriveConflictEvent) -> [Setter] {
[
ProviderEventSchema.id <- event.id.uuidString,
Expand Down Expand Up @@ -875,6 +1058,9 @@ private enum ProviderEventSchema {
static let id = Expression<String>("id")
static let detectedAt = Expression<Double>("detectedAt")
static let resolvedAt = Expression<Double?>("resolvedAt")
static let effectiveConflictDate = SQLite.Expression<Double>(
literal: "COALESCE(\"resolvedAt\", \"detectedAt\")"
)
static let occurredAt = Expression<Double>("occurredAt")
static let domainIdentifier = Expression<String>("domainIdentifier")
static let driveID = Expression<Int>("driveID")
Expand Down
85 changes: 65 additions & 20 deletions doc/APP_AND_DOMAINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,30 +22,47 @@ The app handles:
and sanitized provider activity
- revealing each configured drive through its File Provider user-visible URL
- requesting an immediate working-set refresh for one configured drive
- showing Setup for account and drive configuration
- showing Setup for account and drive configuration through dedicated account
and drive-management destinations

On macOS, the app runs as an accessory menu bar app: it hides its Dock icon and
keeps an atom status item visible while the process is running. Clicking the
status item reveals the setup window, and right-clicking it opens a menu with a
close option. Closing the setup window does not quit the app.

The main window has three tabs: Status, Setup, and Activities. Status is the
first tab and becomes the default once at least one File Provider domain is
configured. If no drives are configured, the app opens on Setup by default while
leaving the empty Status dashboard available.
default tab and is a read-only dashboard. Account and drive actions live under
Setup rather than on the dashboard. Switching tabs uses the same short
slide-and-fade motion as Setup navigation; Reduce Motion removes the horizontal
movement and keeps a brief fade.

Setup uses a three-level navigation hierarchy:

1. Setup lists connected accounts and summarizes discovered and configured
drive counts.
2. An account screen owns rename, drive refresh, logout, and the account's drive
list.
3. Each drive opens a dedicated management screen for its File Provider and,
on macOS, known-folder actions.

Account creation has its own destination. Infomaniak OAuth is the primary path;
manual access-token entry remains available in an Advanced section for
development. While stored state is being restored, Setup shows an explicit
loading row instead of briefly presenting the empty-account state. Setup errors
appear in a nonmodal, dismissible banner so background refreshes do not interrupt
navigation with a transient alert.

The Status dashboard only uses local/provider-safe state: local account records,
configured domain records, currently loaded kDrive summaries, SQLite listing
snapshot aggregates, and sanitized activity/conflict counts. It does not fetch
remote account profile data, quotas, OAuth token details, private links, or file
contents.

Each drive card has a Show in Finder button on macOS or Show in Files on iOS and
visionOS. The app asks that domain's `NSFileProviderManager` for the root
container's user-visible URL and opens it through the system. Sync Now signals
the domain's working-set enumerator, then refreshes the local Status dashboard
after File Provider accepts the request. These controls do not enumerate files
or bypass the extension.
Each configured drive's management screen has a Show in Finder button on macOS
or Show in Files on iOS and visionOS. The app asks that domain's
`NSFileProviderManager` for the root container's user-visible URL and opens it
through the system. Sync Now signals the domain's working-set enumerator. These
controls do not enumerate files or bypass the extension.

The app does not enumerate files itself. File listing is handled by the File
Provider extension after the system asks for an enumerator. Each extension
Expand All @@ -70,6 +87,12 @@ tokens and expired non-refreshable tokens are skipped silently so the account ca
be refreshed manually or reconnected without creating repeated setup-page
errors.

An account's drive list is the union of current remote discovery and stored
domain configurations. A configured drive therefore remains manageable when
remote discovery is unavailable or no longer returns that drive. Its detail
screen uses the saved drive name and explicitly marks remote details as
unavailable.

## Domain Configuration

`ProviderDomainConfiguration` is the local record that connects an Apple File
Expand Down Expand Up @@ -102,7 +125,8 @@ The add flow is:
1. The user adds an account through OAuth or by saving a manual access token.
2. The app creates a local account record and saves the token under that account.
3. The app loads kDrives for that account through `PotassiumKDriveService.listDrives()`.
4. The user chooses a discovered drive row for that account.
4. The user opens a discovered drive and chooses **Add to Files** on its
management screen.
5. `PotassiumProviderAppModel.addDomain()` creates a
`ProviderDomainConfiguration` whose display name is derived from the drive
name and, when needed, the account display name.
Expand All @@ -123,9 +147,9 @@ system's registered display name.

## Desktop & Documents On macOS

On macOS 15 or later, a configured drive row can opt in to Apple's known-folder
feature. This is not arbitrary-folder sync: Apple currently permits Desktop and
Documents to be claimed only together.
On macOS 15 or later, a configured drive's management screen can opt in to
Apple's known-folder feature. This is not arbitrary-folder sync: Apple currently
permits Desktop and Documents to be claimed only together.

The app resolves the existing root-level kDrive directory named `Private` and
uses its item identifier as the common parent for `Private/Desktop` and
Expand All @@ -142,6 +166,12 @@ matching control to stop syncing both folders through `releaseKnownFolders`.

## Removing A Domain

Removal is initiated from the drive-management screen and requires explicit
confirmation. The confirmation identifies the provider-local state being
cleared and states that remote kDrive files are not deleted. After successful
removal, a remotely discovered drive stays on screen in its unconfigured state
and can be added again.

The remove flow is:

1. On macOS, the app refreshes live known-folder state and releases Desktop and
Expand All @@ -156,14 +186,29 @@ The remove flow is:
Removing a domain only removes provider state from this app. It does not delete
remote kDrive files.

## Configured Drive Actions

A configured drive's management screen owns all direct File Provider actions:

- show the domain root in Finder on macOS or Files on other platforms
- request a fresh working-set sync
- remove the drive from Files
- enable, repair, or stop Desktop & Documents sync on supported macOS versions

Only one mutating or provider-management action can run for a drive at a time.
The screen disables conflicting controls and shows operation progress. Drive
discovery refresh remains account-scoped even when requested from a drive
screen.

## Logging Out One Account

Independent logout first removes every File Provider domain tied to that
account, including domain JSON, snapshots, activity/conflict rows, and thumbnail
cache entries. On macOS this includes releasing any known folders owned by those
domains; a release failure stops logout. Only after domain cleanup succeeds does
the app delete that account's keychain token and account JSON. Domains and tokens
for other accounts are left untouched.
Independent logout is confirmed from the account screen. It first removes every
File Provider domain tied to that account, including domain JSON, snapshots,
activity/conflict rows, and thumbnail cache entries. On macOS this includes
releasing any known folders owned by those domains; a release failure stops
logout. Only after domain cleanup succeeds does the app delete that account's
keychain token and account JSON. Domains and tokens for other accounts are left
untouched, and remote kDrive files are never deleted by logout.

For development, `scripts/uninstall-file-provider.sh` can perform the same
domain-detach path outside the UI. It runs the signed macOS app with a hidden
Expand Down
Loading