From 5190a33791faf50b07cbad023170810a1b933bf7 Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Thu, 3 Sep 2026 13:18:56 -0700 Subject: [PATCH 1/4] bindings/swift/example: export/download files, drop text editor Add a file export/download action to FileView using .fileExporter, backed by a chunked downloadToTemp() helper that streams the repository file into a temporary file without loading it into memory. Remove the text "write content" editor (pencil button and WriteContentSheet), since file content now comes in via import rather than typed text. --- bindings/swift/example/Sources/FileView.swift | 150 ++++++++++++------ 1 file changed, 103 insertions(+), 47 deletions(-) diff --git a/bindings/swift/example/Sources/FileView.swift b/bindings/swift/example/Sources/FileView.swift index d9cdd922..f0235804 100644 --- a/bindings/swift/example/Sources/FileView.swift +++ b/bindings/swift/example/Sources/FileView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UniformTypeIdentifiers import CryptoKit import OuisyncLib @@ -8,9 +9,13 @@ struct FileView: View { let path: String @State private var state: FileState = .loading - @State private var isWriting = false + @State private var isExporting = false + @State private var exportDocument: ExportedFile? + @State private var exportProgress: Double? @State private var errorMessage: String? + private var fileName: String { (path as NSString).lastPathComponent } + private var repo: Repository? { viewModel.repositories[repositoryName] } var body: some View { @@ -40,26 +45,31 @@ struct FileView: View { ) } } - .navigationTitle((path as NSString).lastPathComponent) + .navigationTitle(fileName) .task { await loadFile() } + .overlay { if let exportProgress { exportOverlay(exportProgress) } } .toolbar { ToolbarItem { - Button { isWriting = true } label: { Image(systemName: "pencil") } - .help("Write text content") + Button { Task { await prepareExport() } } label: { Image(systemName: "square.and.arrow.down") } + .help("Export / download this file") + .disabled(exportProgress != nil) } ToolbarItem { Button { Task { await loadFile() } } label: { Image(systemName: "arrow.clockwise") } .help("Refresh") } } - .sheet(isPresented: $isWriting) { - WriteContentSheet { text in - isWriting = false - Task { await writeContent(text) } - } onCancel: { - isWriting = false + .fileExporter( + isPresented: $isExporting, + document: exportDocument, + contentType: .data, + defaultFilename: fileName + ) { result in + if case .failure(let error) = result { + errorMessage = error.localizedDescription } - .padding() + exportDocument?.cleanup() + exportDocument = nil } .alert("Error", isPresented: Binding( get: { errorMessage != nil }, @@ -148,53 +158,99 @@ struct FileView: View { } } - // MARK: - Write + // MARK: - Export + + private func exportOverlay(_ fraction: Double) -> some View { + ZStack { + Color.black.opacity(0.2).ignoresSafeArea() + VStack(spacing: 12) { + ProgressView(value: fraction) { Text("Preparing \(fileName)…") } + Text("\(Int(fraction * 100))%").foregroundStyle(.secondary) + } + .padding(24) + .frame(maxWidth: 320) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + } + } + + /// Streams the repository file into a temporary file on disk, updating + /// `exportProgress` as it goes. The returned URL lives in a unique temp + /// subdirectory. Throws (and cleans up) on failure. + private func downloadToTemp() async throws -> URL { + guard let repo else { throw CocoaError(.fileNoSuchFile) } + + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + .appendingPathComponent(fileName) - private func writeContent(_ text: String) async { - guard let repo else { return } do { - let data = Data(text.utf8) - let file = try await repo.createFile(path) + try FileManager.default.createDirectory( + at: tempURL.deletingLastPathComponent(), withIntermediateDirectories: true) + FileManager.default.createFile(atPath: tempURL.path, contents: nil) + + let output = try FileHandle(forWritingTo: tempURL) + defer { try? output.close() } + + let file = try await repo.openFile(path) defer { Task { try? await file.close() } } - try await file.write(0, data) - try await file.flush() - await loadFile() + + let length = try await file.getLength() + let chunkSize: UInt64 = 65536 + var offset: UInt64 = 0 + + while offset < length { + let chunk = try await file.read(offset, min(chunkSize, length - offset)) + if chunk.isEmpty { break } + try output.write(contentsOf: chunk) + offset += UInt64(chunk.count) + exportProgress = length > 0 ? Double(offset) / Double(length) : 1.0 + } + + return tempURL + } catch { + try? FileManager.default.removeItem(at: tempURL.deletingLastPathComponent()) + throw error + } + } + + /// Downloads the file to a temp location and presents the system exporter + /// so the user can save it wherever they like. + private func prepareExport() async { + exportProgress = 0 + do { + let tempURL = try await downloadToTemp() + exportProgress = nil + exportDocument = ExportedFile(url: tempURL) + isExporting = true } catch { - errorMessage = error.localizedDescription + exportProgress = nil + errorMessage = "Failed to export \(fileName): \(error.localizedDescription)" } } } -// MARK: - Write-content sheet +// MARK: - Exported file document -private struct WriteContentSheet: View { - let onSubmit: (String) -> Void - let onCancel: () -> Void +/// Wraps an on-disk temporary file for use with `.fileExporter`. The exporter +/// copies it to the user-chosen destination without loading it into memory. +private struct ExportedFile: FileDocument { + static var readableContentTypes: [UTType] { [.data] } - @State private var text = "" + let url: URL - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Write content").font(.headline) - TextEditor(text: $text) - .font(.system(.body, design: .monospaced)) -#if os(macOS) - .frame(width: 380, height: 160) -#else - .frame(minHeight: 160) -#endif - .border(Color.secondary.opacity(0.3)) - HStack { - Spacer() - Button("Cancel") { onCancel() } - Button("Write") { onSubmit(text) } - .buttonStyle(.borderedProminent) - .disabled(text.isEmpty) - } - } -#if os(macOS) - .frame(width: 400) -#endif + init(url: URL) { self.url = url } + + init(configuration: ReadConfiguration) throws { + throw CocoaError(.fileReadUnsupportedScheme) + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + try FileWrapper(url: url) + } + + /// Removes the temporary directory backing this document. + func cleanup() { + try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } } From 6fc0deb526636555b8da66d85f8a68c0e7156d07 Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Thu, 3 Sep 2026 13:18:56 -0700 Subject: [PATCH 2/4] bindings/swift/example: import, delete, and folder creation Add file import to FolderView: the + button opens a file picker and streams each chosen file into the repository in chunks with a progress overlay; on macOS files can also be dragged in from Finder (drop target highlights with a dotted-arrow icon). Add delete for files and folders via swipe actions and context menu, with a confirmation dialog (recursive for non-empty folders). Replace empty-file creation with a folder-only create button (folder.badge.plus) and a native iOS new-folder sheet. --- .../swift/example/Sources/FolderView.swift | 241 +++++++++++++++--- 1 file changed, 208 insertions(+), 33 deletions(-) diff --git a/bindings/swift/example/Sources/FolderView.swift b/bindings/swift/example/Sources/FolderView.swift index aa898d5d..43f85bde 100644 --- a/bindings/swift/example/Sources/FolderView.swift +++ b/bindings/swift/example/Sources/FolderView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UniformTypeIdentifiers import OuisyncLib struct FolderView: View { @@ -9,8 +10,14 @@ struct FolderView: View { @State private var entries: [DirectoryEntry] = [] @State private var error: String? @State private var isLoading = true - @State private var isCreating = false + @State private var isCreatingFolder = false + @State private var isImporting = false + @State private var importProgress: (name: String, fraction: Double)? + @State private var pendingDelete: DirectoryEntry? @State private var errorMessage: String? +#if os(macOS) + @State private var isDropTargeted = false +#endif private var repo: Repository? { viewModel.repositories[repositoryName] } @@ -28,7 +35,7 @@ struct FolderView: View { ContentUnavailableView( "Empty Folder", systemImage: "folder", - description: Text("Tap + to create a file or directory.") + description: Text("Tap + to import a file, or the folder button to create a directory.") ) } else { entryList @@ -37,23 +44,62 @@ struct FolderView: View { .navigationTitle(path.isEmpty ? repositoryName : "\(repositoryName)\(path)") .task { await loadEntries() } .task(id: repositoryName) { await watchRepository() } + .overlay { if let importProgress { importOverlay(importProgress) } } +#if os(macOS) + .dropDestination(for: URL.self) { urls, _ in + let files = urls.filter { $0.isFileURL } + guard !files.isEmpty, importProgress == nil else { return false } + Task { await importFiles(files) } + return true + } isTargeted: { isDropTargeted = $0 } + .overlay { + if isDropTargeted { + ZStack { + RoundedRectangle(cornerRadius: 16) + .strokeBorder(Color.accentColor, lineWidth: 3) + .background(Color.accentColor.opacity(0.08)) + Image(systemName: "arrow.down.circle.dotted") + .font(.system(size: 72)) + .foregroundStyle(Color.accentColor) + } + .allowsHitTesting(false) + } + } +#endif .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { isCreating = true } label: { Image(systemName: "plus") } + ToolbarItem { + Button { isCreatingFolder = true } label: { Label("New folder", systemImage: "folder.badge.plus") } + .help("New folder") } ToolbarItem { - Button { Task { await loadEntries() } } label: { Image(systemName: "arrow.clockwise") } + Button { isImporting = true } label: { Label("Import a file", systemImage: "plus") } + .help("Import a file") + .disabled(importProgress != nil) + } + ToolbarItem { + Button { Task { await loadEntries() } } label: { Label("Refresh", systemImage: "arrow.clockwise") } .help("Refresh") } } - .sheet(isPresented: $isCreating) { - CreateEntrySheet { name, kind in - isCreating = false - Task { await createEntry(name: name, kind: kind) } + .fileImporter( + isPresented: $isImporting, + allowedContentTypes: [.item], + allowsMultipleSelection: true + ) { result in + switch result { + case .success(let urls): + Task { await importFiles(urls) } + case .failure(let error): + errorMessage = error.localizedDescription + } + } + .sheet(isPresented: $isCreatingFolder) { + NewFolderSheet { name in + isCreatingFolder = false + Task { await createFolder(name: name) } } onCancel: { - isCreating = false + isCreatingFolder = false } - .padding() } .alert("Error", isPresented: Binding( get: { errorMessage != nil }, @@ -63,6 +109,23 @@ struct FolderView: View { } message: { Text(errorMessage ?? "") } + .confirmationDialog( + pendingDelete.map { "Delete \"\($0.name)\"?" } ?? "", + isPresented: Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } }), + titleVisibility: .visible + ) { + if let entry = pendingDelete { + Button("Delete", role: .destructive) { + pendingDelete = nil + Task { await deleteEntry(entry) } + } + } + Button("Cancel", role: .cancel) { pendingDelete = nil } + } message: { + if pendingDelete?.entryType == .directory { + Text("The folder and all its contents will be deleted.") + } + } } // MARK: - Entry list @@ -72,6 +135,16 @@ struct FolderView: View { NavigationLink(value: destinationRoute(for: entry)) { Label(entry.name, systemImage: entry.entryType == .directory ? "folder" : "doc") } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { pendingDelete = entry } label: { + Label("Delete", systemImage: "trash") + } + } + .contextMenu { + Button(role: .destructive) { pendingDelete = entry } label: { + Label("Delete", systemImage: "trash") + } + } } } @@ -109,45 +182,147 @@ struct FolderView: View { isLoading = false } - private func createEntry(name: String, kind: EntryKind) async { + private func createFolder(name: String) async { guard let repo else { return } let entryPath = path.isEmpty ? "/\(name)" : "\(path)/\(name)" do { - switch kind { - case .file: - let file = try await repo.createFile(entryPath) - try await file.close() - case .directory: - try await repo.createDirectory(entryPath) - } + try await repo.createDirectory(entryPath) await loadEntries() } catch { errorMessage = error.localizedDescription } } -} -// MARK: - Create entry sheet + private func deleteEntry(_ entry: DirectoryEntry) async { + guard let repo else { return } + let entryPath = path.isEmpty ? "/\(entry.name)" : "\(path)/\(entry.name)" + do { + switch entry.entryType { + case .file: try await repo.removeFile(entryPath) + case .directory: try await repo.removeDirectory(entryPath, true) + } + await loadEntries() + } catch { + errorMessage = "Failed to delete \(entry.name): \(error.localizedDescription)" + } + } + + // MARK: - Import + + private func importOverlay(_ progress: (name: String, fraction: Double)) -> some View { + ZStack { + Color.black.opacity(0.2).ignoresSafeArea() + VStack(spacing: 12) { + ProgressView(value: progress.fraction) { + Text("Importing \(progress.name)…") + } + Text("\(Int(progress.fraction * 100))%").foregroundStyle(.secondary) + } + .padding(24) + .frame(maxWidth: 320) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + } + } + + private func importFiles(_ urls: [URL]) async { + for url in urls { + await importFile(url) + } + importProgress = nil + await loadEntries() + } + + private func importFile(_ url: URL) async { + guard let repo else { return } + + let name = url.lastPathComponent + let entryPath = path.isEmpty ? "/\(name)" : "\(path)/\(name)" + + // Files chosen outside the app sandbox are security-scoped. + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + + importProgress = (name: name, fraction: 0) + + do { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + + let totalBytes = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize).flatMap { UInt64($0) } + + let file = try await repo.createFile(entryPath) + defer { Task { try? await file.close() } } + + let chunkSize = 65536 + var offset: UInt64 = 0 -enum EntryKind { case file, directory } + while true { + let chunk = try handle.read(upToCount: chunkSize) ?? Data() + if chunk.isEmpty { break } + try await file.write(offset, chunk) + offset += UInt64(chunk.count) -private struct CreateEntrySheet: View { - let onSubmit: (String, EntryKind) -> Void + if let totalBytes, totalBytes > 0 { + importProgress = (name: name, fraction: Double(offset) / Double(totalBytes)) + } + } + + try await file.flush() + } catch { + errorMessage = "Failed to import \(name): \(error.localizedDescription)" + } + } +} + +// MARK: - New folder sheet + +private struct NewFolderSheet: View { + let onSubmit: (String) -> Void let onCancel: () -> Void @State private var name = "" - @State private var kind: EntryKind = .file @State private var nameError = "" var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text("New entry").font(.headline) +#if os(iOS) + NavigationStack { + Form { + Section { + TextField("Name", text: $name) + if !nameError.isEmpty { + Text(nameError).font(.caption).foregroundStyle(.red) + } + } header: { + Text("Name") + } + } + .navigationTitle("New Folder") + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom) { + HStack(spacing: 12) { + Button(role: .cancel) { onCancel() } label: { + Text("Cancel").frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) - Picker("Kind", selection: $kind) { - Text("File").tag(EntryKind.file) - Text("Directory").tag(EntryKind.directory) + Button { + guard validate() else { return } + onSubmit(name) + } label: { + Text("Create").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } + .controlSize(.large) + .padding() + .background(.bar) } - .pickerStyle(.segmented) + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) +#else + VStack(alignment: .leading, spacing: 16) { + Text("New folder").font(.headline) VStack(alignment: .leading, spacing: 4) { TextField("Name", text: $name) @@ -161,12 +336,12 @@ private struct CreateEntrySheet: View { Button("Cancel") { onCancel() } Button("Create") { guard validate() else { return } - onSubmit(name, kind) + onSubmit(name) } .buttonStyle(.borderedProminent) } } -#if os(macOS) + .padding() .frame(width: 280) #endif } From 02faa732f1410eeb06237584eb984b73b8a3e276 Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Thu, 3 Sep 2026 13:18:56 -0700 Subject: [PATCH 3/4] bindings/swift/example: repository row actions and native iOS sheet Add swipe actions and a context menu to repository rows for sharing and deleting, alongside the existing inline buttons. Restyle the create-repository sheet on iOS as a medium-detent Form with Cancel/Create pinned to a bottom bar, instead of a full-height sheet with top toolbar buttons. --- .../example/Sources/RepositoryListView.swift | 69 ++++++++++++++++++- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/bindings/swift/example/Sources/RepositoryListView.swift b/bindings/swift/example/Sources/RepositoryListView.swift index ea51047a..22642953 100644 --- a/bindings/swift/example/Sources/RepositoryListView.swift +++ b/bindings/swift/example/Sources/RepositoryListView.swift @@ -42,9 +42,8 @@ struct RepositoryListView: View { } onCancel: { isCreating = false } - .padding() } - .onChange(of: viewModel.pendingShare) { share in + .onChange(of: viewModel.pendingShare) { _, share in guard let share else { return } initialName = share.suggestedName initialToken = share.token @@ -136,6 +135,27 @@ private struct RepositoryRow: View { .buttonStyle(.borderless) .foregroundStyle(.red) } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { confirmDelete = true } label: { + Label("Delete", systemImage: "trash") + } + if let shareURL = shareToken.flatMap(ouisyncURL(from:)) { + ShareLink(item: shareURL) { + Label("Share", systemImage: "square.and.arrow.up") + } + .tint(.blue) + } + } + .contextMenu { + if let shareURL = shareToken.flatMap(ouisyncURL(from:)) { + ShareLink(item: shareURL) { + Label("Share", systemImage: "square.and.arrow.up") + } + } + Button(role: .destructive) { confirmDelete = true } label: { + Label("Delete", systemImage: "trash") + } + } .task { shareToken = await onShare() } @@ -168,6 +188,49 @@ private struct CreateRepositorySheet: View { } var body: some View { +#if os(iOS) + NavigationStack { + Form { + Section { + TextField("Name", text: $name) + if !nameError.isEmpty { + Text(nameError).font(.caption).foregroundStyle(.red) + } + } header: { + Text("Name") + } + Section { + TextField("Share token", text: $token, axis: .vertical) + .lineLimit(1...3) + } header: { + Text("Share token (optional)") + } + } + .navigationTitle("New Repository") + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom) { + HStack(spacing: 12) { + Button(role: .cancel) { onCancel() } label: { + Text("Cancel").frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + + Button { + guard validate() else { return } + onSubmit(name, token) + } label: { + Text("Create").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } + .controlSize(.large) + .padding() + .background(.bar) + } + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) +#else VStack(alignment: .leading, spacing: 16) { Text("Create Repository").font(.headline) @@ -190,7 +253,7 @@ private struct CreateRepositorySheet: View { .buttonStyle(.borderedProminent) } } -#if os(macOS) + .padding() .frame(width: 320) #endif } From 1af59f7a85e4368fb2c5b6c9e5803e784eee94ff Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Thu, 3 Sep 2026 13:18:56 -0700 Subject: [PATCH 4/4] bindings/swift/example: set app display name and category --- .../swift/example/OuisyncExample.xcodeproj/project.pbxproj | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bindings/swift/example/OuisyncExample.xcodeproj/project.pbxproj b/bindings/swift/example/OuisyncExample.xcodeproj/project.pbxproj index f0b51020..ef882ab9 100644 --- a/bindings/swift/example/OuisyncExample.xcodeproj/project.pbxproj +++ b/bindings/swift/example/OuisyncExample.xcodeproj/project.pbxproj @@ -21,9 +21,9 @@ C0000000000000000000A003 /* RepositoryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryListView.swift; sourceTree = ""; }; C0000000000000000000A004 /* FolderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderView.swift; sourceTree = ""; }; C0000000000000000000A005 /* FileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileView.swift; sourceTree = ""; }; + C0000000000000000000A007 /* OuisyncExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OuisyncExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; C0000000000000000000A009 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; C0000000000000000000A00E /* OuisyncExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OuisyncExample.entitlements; sourceTree = ""; }; - C0000000000000000000A007 /* OuisyncExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OuisyncExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -260,6 +260,8 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 5SR9R72Z83; INFOPLIST_FILE = Sources/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "OuiSync Example App"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -285,6 +287,8 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 5SR9R72Z83; INFOPLIST_FILE = Sources/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "OuiSync Example App"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)",