Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
efd5925
docs: add file provider stability loop plan
OpenCow42 Aug 31, 2026
2fd9fbb
feat: add stability diagnostics profile
OpenCow42 Aug 31, 2026
c348d0c
feat: instrument stability operations
OpenCow42 Aug 31, 2026
871c04d
feat: add safe stability lab
OpenCow42 Aug 31, 2026
347b2a3
feat: add Finder stability runner
OpenCow42 Sep 1, 2026
c61cf6e
fix: align kdrive adapters with pinned evidence
OpenCow42 Sep 1, 2026
5acee67
test: record stability loop validation
OpenCow42 Sep 1, 2026
645ee80
docs: finalize stability loop ledger
OpenCow42 Sep 1, 2026
cc4a9df
test: remove credential-shaped stability fixtures
OpenCow42 Sep 1, 2026
7e463b2
test: isolate profile-specific macOS tests
OpenCow42 Sep 8, 2026
49ac27a
chore: stabilize macOS test schemes
OpenCow42 Sep 8, 2026
d8718c2
feat: add monitored live Finder integration suite
OpenCow42 Sep 10, 2026
2cdd45a
fix: resolve trashed metadata and await Finder rows
OpenCow42 Sep 10, 2026
67a4710
fix: bound Finder preparation and wait for Restore completion
OpenCow42 Sep 10, 2026
bcd6dc2
fix: verify restored Finder destinations and record preflight
OpenCow42 Sep 10, 2026
8211cf3
test: add deterministic conflict matrix and controlled Finder races
OpenCow42 Sep 10, 2026
3de05fa
test: attest extension launches and wait for conflict destinations
OpenCow42 Sep 10, 2026
3916c06
test: verify committed Finder races and destination identity
OpenCow42 Sep 10, 2026
a82a106
fix: deliver confirmed mutations without stale working-set crawls
OpenCow42 Sep 10, 2026
ac7544e
fix: seal Finder evidence after local diagnostic quiescence
OpenCow42 Sep 10, 2026
e508313
fix: retain underlying storage error diagnostics
OpenCow42 Sep 10, 2026
27e008d
fix: tolerate snapshot initialization contention
OpenCow42 Sep 10, 2026
05e8e5f
fix: distinguish provider objects from process restarts
OpenCow42 Sep 10, 2026
b1eeb79
fix: release hydrated documents before Finder eviction
OpenCow42 Sep 10, 2026
a057b29
diagnostics: trace Finder navigation failure stages
OpenCow42 Sep 10, 2026
7262ee1
docs: record Finder navigation and polling limitations
OpenCow42 Sep 10, 2026
e80999e
fix: navigate bound Trash parents through Finder targets
OpenCow42 Sep 10, 2026
3a5eb52
fix: cancel materialization work when provider instances invalidate
OpenCow42 Sep 10, 2026
e774d08
fix: request provider telemetry during warm launch preparation
OpenCow42 Sep 10, 2026
14165a1
fix: isolate conflict setup and signal replicated refreshes
OpenCow42 Sep 10, 2026
d4b5323
fix: confine conflict evidence to intended fixture subjects
OpenCow42 Sep 10, 2026
442225f
docs: record complete updated conflict acceptance profiles
OpenCow42 Sep 10, 2026
4504c76
docs: record remaining original Finder suite blocker
OpenCow42 Sep 11, 2026
fcf1dee
fix: stabilize diagnostic observation and Mac setup accessibility
OpenCow42 Sep 11, 2026
3b15d54
fix: bound concurrent working-set folder reads
OpenCow42 Sep 11, 2026
716f907
fix: package shared concurrency and restore Mac setup hit areas
OpenCow42 Sep 11, 2026
fe763b6
test: control scheduling gates and retain CI result bundles
OpenCow42 Sep 11, 2026
82cfdd3
fix: bind Finder deletion confirmation to its display name
OpenCow42 Sep 11, 2026
9b71d76
feat: make live Finder permanent deletion opt-in
OpenCow42 Sep 11, 2026
e00eee5
fix: record weighted transfer progress before completion
OpenCow42 Sep 11, 2026
1aa4117
fix: open Finder menus from confined selection geometry
OpenCow42 Sep 11, 2026
1fd42e7
fix: dispatch Finder downloads without waiting for completion
OpenCow42 Sep 11, 2026
a525f87
fix: observe live transfers promptly and continue independent checks
OpenCow42 Sep 11, 2026
765a651
fix: include favorite metadata for Finder contextual actions
OpenCow42 Sep 11, 2026
5ae7040
fix: confine native cancellation and attest Actions registration
OpenCow42 Sep 11, 2026
54d43e3
fix: isolate Finder popup roots and continue advanced checks
OpenCow42 Sep 11, 2026
686eb94
fix: deliver confined Finder clicks with bounded UI waits
OpenCow42 Sep 11, 2026
7209e06
fix: open selected Finder actions through its bound toolbar
OpenCow42 Sep 11, 2026
212e54e
fix: preserve provider context menus and use session pointer delivery
OpenCow42 Sep 11, 2026
8af5313
fix: resolve Finder action selections within their provider domain
OpenCow42 Sep 11, 2026
2ebe661
fix: bind hosted Finder action panels through their native main window
OpenCow42 Sep 11, 2026
a961bf5
fix: keep diagnostic file access out of action panel rendering
OpenCow42 Sep 11, 2026
27e5dc9
fix: authorize extension app groups in provisioning profiles
OpenCow42 Sep 11, 2026
98e93d6
fix: reject obstructed Finder pointer targets
OpenCow42 Sep 11, 2026
887bdf2
fix: stabilize file provider operations and Finder validation
OpenCow42 Sep 12, 2026
2a35134
docs: record accepted file comments limitation
OpenCow42 Sep 13, 2026
00bf6a4
docs: record tested OS versions for Finder integration
OpenCow42 Sep 13, 2026
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
43 changes: 42 additions & 1 deletion .github/workflows/xcode-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,36 @@ jobs:
-destination 'platform=macOS' \
CODE_SIGNING_ALLOWED=NO

- name: Run unit tests on macOS
- name: Run unit and UI tests on macOS
run: |
xcodebuild test \
-project potassiumProvider.xcodeproj \
-scheme potassiumProvider \
-destination 'platform=macOS' \
-resultBundlePath "$RUNNER_TEMP/macos-tests.xcresult" \
MACOSX_DEPLOYMENT_TARGET=26.4 \
CODE_SIGNING_ALLOWED=NO

- name: Run Stability profile unit tests on macOS
if: ${{ !cancelled() }}
run: |
xcodebuild test \
-project potassiumProvider.xcodeproj \
-scheme potassiumProvider-Stability \
-destination 'platform=macOS' \
-resultBundlePath "$RUNNER_TEMP/macos-stability-tests.xcresult" \
MACOSX_DEPLOYMENT_TARGET=26.4 \
CODE_SIGNING_ALLOWED=NO

- name: Retain Mac test result bundles
if: always()
uses: actions/upload-artifact@v7
with:
name: macos-test-results
path: ${{ runner.temp }}/macos*-tests.xcresult
retention-days: 14
if-no-files-found: warn

ios:
name: iOS Simulator
runs-on: macos-26
Expand All @@ -58,8 +79,18 @@ jobs:
-scheme potassiumProvider \
-destination 'platform=iOS Simulator,OS=26.5,name=iPhone 17' \
-only-testing:potassiumProviderTests \
-resultBundlePath "$RUNNER_TEMP/ios-tests.xcresult" \
CODE_SIGNING_ALLOWED=NO

- name: Retain iOS test result bundle
if: always()
uses: actions/upload-artifact@v7
with:
name: ios-test-results
path: ${{ runner.temp }}/ios-tests.xcresult
retention-days: 14
if-no-files-found: warn

visionos:
name: visionOS
runs-on: macos-26
Expand All @@ -83,4 +114,14 @@ jobs:
-scheme potassiumProvider \
-destination 'platform=visionOS Simulator,OS=26.5,name=Apple Vision Pro' \
-only-testing:potassiumProviderTests \
-resultBundlePath "$RUNNER_TEMP/visionos-tests.xcresult" \
CODE_SIGNING_ALLOWED=NO

- name: Retain visionOS test result bundle
if: always()
uses: actions/upload-artifact@v7
with:
name: visionos-test-results
path: ${{ runner.temp }}/visionos-tests.xcresult
retention-days: 14
if-no-files-found: warn
14 changes: 13 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ File Provider experience.
`potassiumProviderFileProvider`, `potassiumProviderActions`,
`potassiumProviderTests`, and `potassiumProviderUITests`
- Supported validation platforms: iOS Simulator, macOS, and visionOS
- Dependencies: `SQLite.swift` and `potassiumChannel` package products
- Dependencies: `InfomaniakConcurrency`, `SQLite.swift`, and `potassiumChannel` package products
`PotassiumChannelCore`, `PotassiumKDrive`, and `PotassiumOAuth`
- Tests: Swift Testing for unit tests, XCTest for UI tests

Expand Down Expand Up @@ -152,6 +152,15 @@ app.
- Keep networking behavior testable with mocks, fixtures, or injectable
clients. Live checks must be explicit, locally guarded, and kept out of the
default test path.
- Provision new Stability Lab roots below the server-created `Private` folder,
verifying its stable identity and drive-root parent; do not assume the drive
root accepts creates. Never use the `Private` folder itself as a disposable root.
- The current account is an operator-authorized lab account. Stability may reuse
its existing OAuth Keychain login as well as manually entered tokens; never
extract a credential into a command, fixture, environment variable, or log.
- Live Finder runs defer permanent deletion by default and continue later scenarios.
Use `--include-permanent-deletion` only when requested; retain exact-item
confirmation and never count deferred deletion as full-suite acceptance.
- Prefer typed request/response flows from Potassium products over app-local
ad hoc HTTP construction.

Expand Down Expand Up @@ -207,6 +216,9 @@ app.
- Use Swift Testing (`import Testing`) for new unit tests unless the work is in
existing XCTest UI test targets.
- Use XCTest only for UI automation or when extending existing XCTest files.
- Record the actual OS version/build and Xcode version with integration-test
evidence. Rerun native Finder integration after macOS upgrades before claiming
compatibility; an SDK or deployment target is not a tested runtime version.
- Run the relevant `xcodebuild build` or `xcodebuild test` command before
describing implementation work as complete, including Mac and visionOS
destinations when changes affect runtime behavior. If validation cannot be
Expand Down
2 changes: 2 additions & 0 deletions Config/potassiumProviderInfo.plist
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
<true/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>NSAppleEventsUsageDescription</key>
<string>The opt-in Stability Lab uses Finder Automation only to exercise its verified disposable File Provider root.</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
Expand Down
18 changes: 18 additions & 0 deletions Config/potassiumProviderStability.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.net.weavee.potassiumProvider</string>
</array>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)net.weavee.potassiumProvider</string>
</array>
</dict>
</plist>
56 changes: 56 additions & 0 deletions PotassiumProviderCore/FileProviderBackgroundWork.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Foundation
import Synchronization

/// Owns work launched after a File Provider callback has been acknowledged.
/// `invalidate()` is synchronous because the system's instance invalidation
/// callback must cancel outstanding work before it returns. The mutex protects
/// task registration only; operations and cancellation handlers run outside it.
public final class FileProviderBackgroundWork: Sendable {
private struct State {
var invalidated = false
var tasks: [UUID: Task<Void, Never>] = [:]
}
private let state = Mutex(State())

public init() {}

/// Returns false once this instance has been invalidated. A new provider
/// instance must own a new scope even when it shares the same process.
@discardableResult
public func start(_ operation: @escaping @Sendable () async -> Void) -> Bool {
state.withLock { state in
guard !state.invalidated else { return false }
let identifier = UUID()
state.tasks[identifier] = Task {
// Registration holds the same mutex, so an immediately
// finishing task cannot remove itself before insertion.
guard self.canBegin(identifier) else { return }
defer { self.finished(identifier) }
await operation()
}
return true
}
}

public func invalidate() {
let tasks = state.withLock { state in
state.invalidated = true
let tasks = Array(state.tasks.values)
state.tasks.removeAll()
return tasks
}
// Task cancellation can synchronously invoke a handler. Never call
// it while holding the registration mutex.
for task in tasks { task.cancel() }
}

var registeredTaskCount: Int { state.withLock { $0.tasks.count } }

private func canBegin(_ identifier: UUID) -> Bool {
state.withLock { !$0.invalidated && $0.tasks[identifier] != nil && !Task.isCancelled }
}

private func finished(_ identifier: UUID) {
state.withLock { $0.tasks[identifier] = nil }
}
}
57 changes: 51 additions & 6 deletions PotassiumProviderCore/FileProviderOperationLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,34 @@ public actor FileProviderOperationLifecycle {
public nonisolated let progress: Progress

private let cancellationCompletion: @Sendable () -> Void
private let diagnosticSpanTask: Task<ProviderDiagnosticSpan, Never>?
private var task: Task<Void, Never>?
private var isFinished = false

public init(
progress: Progress,
diagnosticSource: ProviderDiagnosticSource = .fileProviderExtension,
diagnosticOperation: ProviderDiagnosticOperation? = nil,
diagnosticFieldShape: [ProviderDiagnosticField] = [],
diagnosticItemIdentifier: String? = nil,
diagnosticRecorder: (any ProviderDiagnosticRecording)? = nil,
cancellationCompletion: @escaping @Sendable () -> Void
) {
self.progress = progress
self.cancellationCompletion = cancellationCompletion
if let diagnosticOperation {
self.diagnosticSpanTask = Task {
await ProviderDiagnosticSpan.start(
itemIdentifier: diagnosticItemIdentifier,
source: diagnosticSource,
operation: diagnosticOperation,
fieldShape: diagnosticFieldShape,
recorder: diagnosticRecorder
)
}
} else {
self.diagnosticSpanTask = nil
}
progress.isCancellable = true
progress.isPausable = false
progress.cancellationHandler = { [weak self] in
Expand All @@ -34,24 +53,38 @@ public actor FileProviderOperationLifecycle {

private func begin(
_ operation: @escaping @Sendable (FileProviderOperationLifecycle) async -> Void
) {
) async {
guard isFinished == false else { return }
guard progress.isCancelled == false else {
cancel()
await cancel()
return
}
let diagnosticSpan = await diagnosticSpanTask?.value
guard isFinished == false else { return }
guard progress.isCancelled == false else {
await cancel()
return
}
task = Task {
await operation(self)
if let diagnosticSpan {
await diagnosticSpan.withCorrelation {
await operation(self)
}
} else {
await operation(self)
}
}
}

@discardableResult
public func finish(
markProgressComplete: Bool,
diagnosticError: (any Error)? = nil,
diagnosticItemMetadataAlias: UUID? = nil,
_ completion: @escaping @Sendable () -> Void
) -> Bool {
) async -> Bool {
if progress.isCancelled {
cancel()
await cancel()
return false
}

Expand All @@ -62,11 +95,19 @@ public actor FileProviderOperationLifecycle {
if markProgressComplete, progress.totalUnitCount > 0 {
progress.completedUnitCount = progress.totalUnitCount
}
if let diagnosticSpanTask {
let diagnosticSpan = await diagnosticSpanTask.value
if let diagnosticError {
await diagnosticSpan.fail(error: diagnosticError)
} else {
await diagnosticSpan.complete(statusClass: .success, itemMetadataAlias: diagnosticItemMetadataAlias)
}
}
completion()
return true
}

public func cancel() {
public func cancel() async {
guard isFinished == false else { return }
isFinished = true
let task = task
Expand All @@ -76,6 +117,10 @@ public actor FileProviderOperationLifecycle {
progress.cancel()
}
task?.cancel()
if let diagnosticSpanTask {
let diagnosticSpan = await diagnosticSpanTask.value
await diagnosticSpan.cancel()
}
cancellationCompletion()
}
}
44 changes: 42 additions & 2 deletions PotassiumProviderCore/KDriveContextActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Foundation
public struct KDriveShareLinkConfiguration: Equatable, Sendable {
public enum Access: String, CaseIterable, Equatable, Sendable {
case `public`
case inherit
case password
}

Expand Down Expand Up @@ -45,6 +46,18 @@ public struct KDriveShareLinkConfiguration: Equatable, Sendable {
public func isValid(preservingPasswordFor existingAccess: Access?) -> Bool {
isValid || (access == .password && existingAccess == .password)
}

/// Compare the settings kDrive reports after a write. Passwords are not
/// returned by the service, and expiration is transported in whole seconds.
/// A successful HTTP response alone does not prove that these values stuck.
public func hasSameReportedSettings(as other: Self) -> Bool {
access == other.access &&
validUntil.map { $0.timeIntervalSince1970.rounded(.towardZero) } ==
other.validUntil.map { $0.timeIntervalSince1970.rounded(.towardZero) } &&
allowsDownload == other.allowsDownload && allowsComments == other.allowsComments &&
allowsEditing == other.allowsEditing && allowsAccessRequests == other.allowsAccessRequests &&
showsFileInformation == other.showsFileInformation && showsStatistics == other.showsStatistics
}
}

public struct KDriveShareLinkSummary: Equatable, Sendable {
Expand Down Expand Up @@ -101,7 +114,7 @@ public struct KDriveFileVersionPage: Equatable, Sendable {

public protocol KDriveContextActionProviding: Sendable {
func setFavorite(driveID: Int, fileID: Int, isFavorite: Bool) async throws
func duplicateItem(driveID: Int, fileID: Int) async throws -> KDriveRemoteItem
func duplicateItem(driveID: Int, fileID: Int, name: String) async throws -> KDriveRemoteItem
func trashedItem(driveID: Int, fileID: Int) async throws -> KDriveRemoteItem
func existingFileIDs(driveID: Int, fileIDs: [Int]) async throws -> Set<Int>
func restoreTrashedItem(driveID: Int, fileID: Int, destinationParentID: Int) async throws
Expand Down Expand Up @@ -131,6 +144,8 @@ public enum KDriveContextActionError: Error, Equatable, LocalizedError, Sendable
case invalidShareLinkURL
case passwordRequired
case restoredItemUnavailable
case unsupportedShareLinkAccess
case shareLinkSettingsNotApplied

public var errorDescription: String? {
switch self {
Expand All @@ -140,6 +155,10 @@ public enum KDriveContextActionError: Error, Equatable, LocalizedError, Sendable
return "Enter a password for the protected share link."
case .restoredItemUnavailable:
return "kDrive restored the version, but its metadata is not available yet."
case .unsupportedShareLinkAccess:
return "kDrive returned an unsupported share-link access policy."
case .shareLinkSettingsNotApplied:
return "kDrive did not apply all requested sharing settings. The current server settings are shown; review them before sharing the link."
}
}
}
Expand Down Expand Up @@ -215,6 +234,21 @@ public struct KDriveContextActionExecution: Equatable, Sendable {
}
}

public enum KDriveDuplicateNamePolicy {
/// Produces an explicit, human-readable destination name. The duplicate
/// endpoint's empty-body/server-selected naming behavior is not part of the
/// pinned public contract, so callers never rely on it.
public static func duplicateName(for sourceName: String) -> String {
let path = sourceName as NSString
let pathExtension = path.pathExtension
let baseName = path.deletingPathExtension
guard pathExtension.isEmpty == false, baseName.isEmpty == false else {
return "\(sourceName) copy"
}
return "\(baseName) copy.\(pathExtension)"
}
}

/// Coordinates action-specific remote calls and returns the exact provider
/// containers that must be invalidated after the server mutation succeeds.
public struct KDriveContextActionCoordinator: Sendable {
Expand Down Expand Up @@ -246,7 +280,13 @@ public struct KDriveContextActionCoordinator: Sendable {
return try await setFavorite(false, fileID: fileID, action: action)
case .duplicate:
try Task.checkCancellation()
let duplicate = try await actions.duplicateItem(driveID: driveID, fileID: fileID)
let source = try await remote.item(driveID: driveID, fileID: fileID)
try Task.checkCancellation()
let duplicate = try await actions.duplicateItem(
driveID: driveID,
fileID: fileID,
name: KDriveDuplicateNamePolicy.duplicateName(for: source.name)
)
let authoritativeDuplicate = try await remote.item(driveID: driveID, fileID: duplicate.id)
return KDriveContextActionExecution(
action: action,
Expand Down
Loading