Skip to content

Commit b981469

Browse files
committed
feat(safari): let users delete iCloud backups
1 parent a4db31c commit b981469

18 files changed

Lines changed: 288 additions & 1 deletion

Voyager/Extension/ICloudSyncService.swift

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@ import Foundation
44
final class ICloudSyncService {
55
static let shared = ICloudSyncService()
66

7+
private static let temporaryFilePrefix = "voyager-icloud-"
78
private let container = CKContainer(identifier: "iCloud.com.yourCompany.Gemini-Voyager")
89

10+
private init() {
11+
removeOrphanedTemporaryFiles()
12+
}
13+
914
private var database: CKDatabase {
1015
container.privateCloudDatabase
1116
}
@@ -24,7 +29,7 @@ final class ICloudSyncService {
2429

2530
func write(fileName: String, json: String, completion: @escaping (Result<Void, Error>) -> Void) {
2631
let temporaryURL = FileManager.default.temporaryDirectory
27-
.appendingPathComponent("voyager-\(UUID().uuidString).json")
32+
.appendingPathComponent("\(Self.temporaryFilePrefix)\(UUID().uuidString).json")
2833

2934
do {
3035
try Data(json.utf8).write(to: temporaryURL, options: .atomic)
@@ -97,6 +102,68 @@ final class ICloudSyncService {
97102
}
98103
}
99104

105+
func deleteBackup(completion: @escaping (Result<Int, Error>) -> Void) {
106+
collectRecordIDs(cursor: nil, collected: [], completion: completion)
107+
}
108+
109+
private func collectRecordIDs(
110+
cursor: CKQueryOperation.Cursor?,
111+
collected: [CKRecord.ID],
112+
completion: @escaping (Result<Int, Error>) -> Void
113+
) {
114+
let operation: CKQueryOperation
115+
if let cursor {
116+
operation = CKQueryOperation(cursor: cursor)
117+
} else {
118+
operation = CKQueryOperation(
119+
query: CKQuery(recordType: "VoyagerSyncFile", predicate: NSPredicate(value: true))
120+
)
121+
}
122+
123+
var recordIDs = collected
124+
operation.desiredKeys = []
125+
operation.recordFetchedBlock = { record in
126+
recordIDs.append(record.recordID)
127+
}
128+
operation.queryCompletionBlock = { nextCursor, error in
129+
if let error {
130+
completion(.failure(VoyagerICloudFailureMapper.map(error: error)))
131+
return
132+
}
133+
if let nextCursor {
134+
self.collectRecordIDs(
135+
cursor: nextCursor,
136+
collected: recordIDs,
137+
completion: completion
138+
)
139+
return
140+
}
141+
self.delete(recordIDs: recordIDs, completion: completion)
142+
}
143+
database.add(operation)
144+
}
145+
146+
private func delete(
147+
recordIDs: [CKRecord.ID],
148+
completion: @escaping (Result<Int, Error>) -> Void
149+
) {
150+
guard !recordIDs.isEmpty else {
151+
completion(.success(0))
152+
return
153+
}
154+
155+
let operation = CKModifyRecordsOperation(recordIDsToDelete: recordIDs)
156+
operation.isAtomic = true
157+
operation.modifyRecordsCompletionBlock = { _, deletedRecordIDs, error in
158+
if let error {
159+
completion(.failure(VoyagerICloudFailureMapper.map(error: error)))
160+
} else {
161+
completion(.success(deletedRecordIDs?.count ?? recordIDs.count))
162+
}
163+
}
164+
database.add(operation)
165+
}
166+
100167
private func save(
101168
record: CKRecord,
102169
fileName: String,
@@ -125,4 +192,18 @@ final class ICloudSyncService {
125192
CKRecord.ID(recordName: VoyagerICloudRecordIdentity.recordName(for: fileName))
126193
}
127194

195+
private func removeOrphanedTemporaryFiles() {
196+
let fileManager = FileManager.default
197+
let directory = fileManager.temporaryDirectory
198+
guard
199+
let files = try? fileManager.contentsOfDirectory(
200+
at: directory,
201+
includingPropertiesForKeys: nil
202+
)
203+
else { return }
204+
205+
for file in files where file.lastPathComponent.hasPrefix(Self.temporaryFilePrefix) {
206+
try? fileManager.removeItem(at: file)
207+
}
208+
}
128209
}

Voyager/Extension/SafariWebExtensionHandler.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ final class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
5151
writeICloudFile(request: request, context: context)
5252
case .iCloudReadFile(let fileName):
5353
readICloudFile(fileName: fileName, context: context)
54+
case .iCloudDeleteBackup:
55+
deleteICloudBackup(context: context)
5456
case .copyImageToPasteboard(let request):
5557
copyImageToPasteboard(request: request, context: context)
5658
}
@@ -121,6 +123,20 @@ final class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
121123
}
122124
}
123125

126+
private func deleteICloudBackup(context: NSExtensionContext) {
127+
ICloudSyncService.shared.deleteBackup { result in
128+
switch result {
129+
case .success(let deleted):
130+
self.respondWithSuccess(
131+
context: context,
132+
data: VoyagerICloudDeleteResponse(deleted: deleted)
133+
)
134+
case .failure(let error):
135+
self.respondWithICloudError(context: context, error: error)
136+
}
137+
}
138+
}
139+
124140
private func respondWithICloudError(context: NSExtensionContext, error: Error) {
125141
guard let failure = error as? VoyagerICloudFailure else {
126142
respondWithError(context: context, message: error.localizedDescription)

Voyager/Shared/NativeMessaging.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ enum VoyagerNativeRequest: Codable, Equatable {
1313
case iCloudAccountStatus
1414
case iCloudWriteFile(VoyagerICloudWriteRequest)
1515
case iCloudReadFile(fileName: String)
16+
case iCloudDeleteBackup
1617
case copyImageToPasteboard(VoyagerClipboardImageRequest)
1718

1819
var actionName: String {
@@ -29,6 +30,7 @@ enum VoyagerNativeRequest: Codable, Equatable {
2930
case .iCloudAccountStatus: return Action.iCloudAccountStatus.rawValue
3031
case .iCloudWriteFile: return Action.iCloudWriteFile.rawValue
3132
case .iCloudReadFile: return Action.iCloudReadFile.rawValue
33+
case .iCloudDeleteBackup: return Action.iCloudDeleteBackup.rawValue
3234
case .copyImageToPasteboard: return Action.copyImageToPasteboard.rawValue
3335
}
3436
}
@@ -46,6 +48,7 @@ enum VoyagerNativeRequest: Codable, Equatable {
4648
case iCloudAccountStatus
4749
case iCloudWriteFile
4850
case iCloudReadFile
51+
case iCloudDeleteBackup
4952
case copyImageToPasteboard
5053
}
5154

@@ -122,6 +125,8 @@ enum VoyagerNativeRequest: Codable, Equatable {
122125
self = .iCloudReadFile(
123126
fileName: try container.decode(String.self, forKey: .fileName)
124127
)
128+
case .iCloudDeleteBackup:
129+
self = .iCloudDeleteBackup
125130
case .copyImageToPasteboard:
126131
self = .copyImageToPasteboard(
127132
VoyagerClipboardImageRequest(
@@ -173,6 +178,8 @@ enum VoyagerNativeRequest: Codable, Equatable {
173178
case .iCloudReadFile(let fileName):
174179
try container.encode(Action.iCloudReadFile, forKey: .action)
175180
try container.encode(fileName, forKey: .fileName)
181+
case .iCloudDeleteBackup:
182+
try container.encode(Action.iCloudDeleteBackup, forKey: .action)
176183
case .copyImageToPasteboard(let request):
177184
try container.encode(Action.copyImageToPasteboard, forKey: .action)
178185
try container.encode(request.pngBase64, forKey: .pngBase64)
@@ -341,4 +348,8 @@ struct VoyagerICloudReadResponse: Codable {
341348
let found: Bool
342349
}
343350

351+
struct VoyagerICloudDeleteResponse: Codable {
352+
let deleted: Int
353+
}
354+
344355
struct VoyagerEmptyResponse: Codable {}

Voyager/Tests/NativeSupportTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ final class NativeSupportTests: XCTestCase {
274274
["action": "iCloudReadFile", "fileName": "prompts.json"],
275275
.iCloudReadFile(fileName: "prompts.json")
276276
),
277+
(["action": "iCloudDeleteBackup"], .iCloudDeleteBackup),
277278
(
278279
["action": "copyImageToPasteboard", "pngBase64": "aGVsbG8="],
279280
.copyImageToPasteboard(VoyagerClipboardImageRequest(pngBase64: "aGVsbG8="))

src/core/utils/__tests__/safariICloudSync.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
33
import {
44
SafariICloudSyncError,
55
checkSafariICloudAccount,
6+
deleteSafariICloudBackup,
67
getSafariICloudRetryDelay,
78
isSafariICloudConflictError,
89
readSafariICloudFile,
@@ -55,6 +56,15 @@ describe('Safari iCloud sync bridge', () => {
5556
await expect(readSafariICloudFile('prompts.json')).resolves.toBeNull();
5657
});
5758

59+
it('deletes every native iCloud backup record', async () => {
60+
sendNativeMessage.mockResolvedValue({ success: true, data: { deleted: 4 } });
61+
62+
await expect(deleteSafariICloudBackup()).resolves.toBe(4);
63+
expect(sendNativeMessage).toHaveBeenCalledWith('com.yourCompany.Gemini-Voyager', {
64+
action: 'iCloudDeleteBackup',
65+
});
66+
});
67+
5868
it('surfaces native CloudKit errors', async () => {
5969
sendNativeMessage.mockResolvedValue({ success: false, error: 'iCloud is unavailable' });
6070

src/core/utils/safariICloudSync.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ type NativeICloudResponse = {
88
available?: unknown;
99
found?: unknown;
1010
json?: unknown;
11+
deleted?: unknown;
1112
};
1213
code?: unknown;
1314
error?: unknown;
@@ -80,3 +81,11 @@ export async function readSafariICloudFile<T>(fileName: string): Promise<T | nul
8081
}
8182
return JSON.parse(response.data.json) as T;
8283
}
84+
85+
export async function deleteSafariICloudBackup(): Promise<number> {
86+
const response = await sendICloudMessage({ action: 'iCloudDeleteBackup' });
87+
if (response.success !== true || typeof response.data?.deleted !== 'number') {
88+
throw responseError(response, 'iCloud backup deletion failed');
89+
}
90+
return response.data.deleted;
91+
}

src/locales/ar/messages.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1239,6 +1239,15 @@
12391239
"message": "هل تريد استبدال البيانات المحلية بنسخة السحابة؟ ستُفقد التغييرات المحلية التي لم يتم رفعها.",
12401240
"description": "Confirmation before overwriting local sync data"
12411241
},
1242+
"syncDeleteICloudBackup": { "message": "حذف نسخة iCloud الاحتياطية" },
1243+
"syncDeleteICloudConfirm": {
1244+
"message": "هل تريد حذف جميع نسخ Voyager الاحتياطية من iCloud؟ ستبقى البيانات المحلية على هذا الـ Mac. لا يمكن التراجع عن هذا الإجراء."
1245+
},
1246+
"syncDeletingICloudBackup": { "message": "جارٍ حذف نسخة iCloud الاحتياطية…" },
1247+
"syncDeleteICloudSuccess": {
1248+
"message": "✓ تم حذف {count} من ملفات iCloud الاحتياطية؛ لم تتغير البيانات المحلية"
1249+
},
1250+
"syncDeleteICloudFailed": { "message": "✗ تعذر حذف نسخة iCloud الاحتياطية: {error}" },
12421251
"syncMode": {
12431252
"message": "وضع المزامنة",
12441253
"description": "Sync mode label"

src/locales/en/messages.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1239,6 +1239,15 @@
12391239
"message": "Overwrite local data with the cloud copy? Local changes that have not been uploaded will be lost.",
12401240
"description": "Confirmation before overwriting local sync data"
12411241
},
1242+
"syncDeleteICloudBackup": { "message": "Delete iCloud backup" },
1243+
"syncDeleteICloudConfirm": {
1244+
"message": "Delete every Voyager backup from iCloud? Local data will stay on this Mac. This cannot be undone."
1245+
},
1246+
"syncDeletingICloudBackup": { "message": "Deleting iCloud backup…" },
1247+
"syncDeleteICloudSuccess": {
1248+
"message": "✓ Deleted {count} iCloud backup files; local data is unchanged"
1249+
},
1250+
"syncDeleteICloudFailed": { "message": "✗ Could not delete iCloud backup: {error}" },
12421251
"syncMode": {
12431252
"message": "Sync Mode",
12441253
"description": "Sync mode label"

src/locales/es/messages.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1239,6 +1239,15 @@
12391239
"message": "¿Sobrescribir los datos locales con la copia de la nube? Se perderán los cambios locales que no se hayan subido.",
12401240
"description": "Confirmation before overwriting local sync data"
12411241
},
1242+
"syncDeleteICloudBackup": { "message": "Eliminar copia de iCloud" },
1243+
"syncDeleteICloudConfirm": {
1244+
"message": "¿Eliminar todas las copias de Voyager de iCloud? Los datos locales seguirán en este Mac. Esta acción no se puede deshacer."
1245+
},
1246+
"syncDeletingICloudBackup": { "message": "Eliminando copia de iCloud…" },
1247+
"syncDeleteICloudSuccess": {
1248+
"message": "✓ Se eliminaron {count} archivos de iCloud; los datos locales no cambiaron"
1249+
},
1250+
"syncDeleteICloudFailed": { "message": "✗ No se pudo eliminar la copia de iCloud: {error}" },
12421251
"syncMode": {
12431252
"message": "Modo sinc.",
12441253
"description": "Sync mode label"

src/locales/fr/messages.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1239,6 +1239,17 @@
12391239
"message": "Remplacer les données locales par la copie du cloud ? Les modifications locales non téléversées seront perdues.",
12401240
"description": "Confirmation before overwriting local sync data"
12411241
},
1242+
"syncDeleteICloudBackup": { "message": "Supprimer la sauvegarde iCloud" },
1243+
"syncDeleteICloudConfirm": {
1244+
"message": "Supprimer toutes les sauvegardes Voyager d’iCloud ? Les données locales resteront sur ce Mac. Cette action est irréversible."
1245+
},
1246+
"syncDeletingICloudBackup": { "message": "Suppression de la sauvegarde iCloud…" },
1247+
"syncDeleteICloudSuccess": {
1248+
"message": "✓ {count} fichiers de sauvegarde iCloud supprimés ; les données locales sont inchangées"
1249+
},
1250+
"syncDeleteICloudFailed": {
1251+
"message": "✗ Impossible de supprimer la sauvegarde iCloud : {error}"
1252+
},
12421253
"syncMode": {
12431254
"message": "Mode",
12441255
"description": "Sync mode label"

0 commit comments

Comments
 (0)