diff --git a/apple/VniDrop/App/RootView.swift b/apple/VniDrop/App/RootView.swift index d2f67b4..5a77d2d 100644 --- a/apple/VniDrop/App/RootView.swift +++ b/apple/VniDrop/App/RootView.swift @@ -39,6 +39,7 @@ struct RootView: View { environment: dependencies.environment, deviceInfoProvider: dependencies.deviceInfoProvider, fileSystemService: dependencies.fileSystemService, + repository: graph.coreRepository, preferences: graph.preferencesRepository, notifications: dependencies.notificationService, messages: graph.messages, @@ -67,7 +68,7 @@ struct RootView: View { } .platformPickers(settingsModel: settingsModel) .task { await consumeExternalInvitations() } - .onChange(of: scenePhase) { phase in + .onChange(of: scenePhase) { _, phase in switch phase { case .active: graph.visibility.setForeground(true) @@ -85,7 +86,7 @@ struct RootView: View { // A pending approval is a blocking modal; close the sender's detail panel // (e.g. the Share/QR sheet) so the approval sheet isn't presented under it // on macOS. - .onChange(of: approvals.state.current?.id) { id in + .onChange(of: approvals.state.current?.id) { _, id in if id != nil { sendModel.closeDetailPanel() } } #if os(macOS) diff --git a/apple/VniDrop/Core/TransferProgress.swift b/apple/VniDrop/Core/TransferProgress.swift index e23dde1..a439335 100644 --- a/apple/VniDrop/Core/TransferProgress.swift +++ b/apple/VniDrop/Core/TransferProgress.swift @@ -110,69 +110,6 @@ func progressForReceiver( ) } -/// Send-side progress summary for a transfer's list row. Aggregates only the -/// receivers that are *currently downloading* (in-flight), so the bar disappears -/// once every receiver has completed/aborted, and its value is deterministic -/// (order-independent) rather than "whichever receiver's event arrived last". -func sendProgressSummary( - events: [CoreEventModel], - transferId: UInt64, - totalSizeHint: UInt64? = nil -) -> TransferProgress? { - let endpointIds = events - .filter { $0.transferId == transferId && $0.direction == "send" && $0.phase == "transfer" } - .compactMap { findString($0.dataJson, key: "endpoint_id") } - .reduce(into: [String]()) { acc, id in if !acc.contains(id) { acc.append(id) } } - - // No per-endpoint attribution: fall back to the single connection-scoped - // stream, hidden once it completes/aborts. - if endpointIds.isEmpty { - let relevant = events.filter { - $0.transferId == transferId && $0.direction == "send" && $0.phase == "transfer" - && ["started", "progress", "completed", "aborted"].contains($0.kind) - } - guard let latest = relevant.first else { return nil } - if latest.kind == "completed" || latest.kind == "aborted" { return nil } - let live = relevant.filter { ["started", "progress"].contains($0.kind) } - return TransferProgress( - transferId: transferId, phase: "transfer", kind: latest.kind, - labelKey: "progress_sending", - progress: aggregateReceiverProgress(events: live, totalSizeHint: totalSizeHint), - detail: progressDetail(latest) - ) - } - - // Keep only receivers still in flight (latest state started/progress). - var activeCount = 0 - var fractions: [Double] = [] - var lastActive: TransferProgress? - for id in endpointIds { - guard let p = progressForReceiver( - events: events, transferId: transferId, remoteEndpointId: id, totalSizeHint: totalSizeHint - ) else { continue } - guard p.kind == "progress" || p.kind == "started" else { continue } - activeCount += 1 - lastActive = p - if let fraction = p.progress { fractions.append(fraction) } - } - if activeCount == 0 { return nil } - - // Same total per receiver, so the byte-summed progress is the mean of the - // per-receiver fractions. - let combined = fractions.isEmpty ? nil : fractions.reduce(0, +) / Double(fractions.count) - if activeCount == 1 { - return TransferProgress( - transferId: transferId, phase: "transfer", kind: "progress", - labelKey: "progress_sending", progress: combined, detail: lastActive?.detail - ) - } - return TransferProgress( - transferId: transferId, phase: "transfer", kind: "progress", - labelKey: "progress_sending", progress: combined, - label: String(format: String(localized: "progress_sending_to_count"), activeCount) - ) -} - func formatBytes(_ size: UInt64) -> String { var scaled = Double(size) let units = ["B", "KB", "MB", "GB", "TB"] diff --git a/apple/VniDrop/Features/Receive/ReceiveModel.swift b/apple/VniDrop/Features/Receive/ReceiveModel.swift index 15ca0b0..ed3b871 100644 --- a/apple/VniDrop/Features/Receive/ReceiveModel.swift +++ b/apple/VniDrop/Features/Receive/ReceiveModel.swift @@ -176,7 +176,7 @@ final class ReceiveModel: ObservableObject { text: .resource("receive_completed"), tone: .success, actionLabel: canReveal ? .resource("button_show_in_files") : nil, - onAction: canReveal ? { [weak self] in self?.revealReceiveFolder(folder) } : nil + onAction: canReveal ? { self.revealReceiveFolder(folder) } : nil )) case .failure(let error): if error.isUserCancellation { @@ -193,7 +193,7 @@ final class ReceiveModel: ObservableObject { text: uiText, tone: .error, actionLabel: .resource("button_retry"), - onAction: { [weak self] in self?.receive() } + onAction: { self.receive() } )) } } diff --git a/apple/VniDrop/Features/Receive/ReceiveScreen.swift b/apple/VniDrop/Features/Receive/ReceiveScreen.swift index 8692d0f..9bd50e8 100644 --- a/apple/VniDrop/Features/Receive/ReceiveScreen.swift +++ b/apple/VniDrop/Features/Receive/ReceiveScreen.swift @@ -17,7 +17,7 @@ struct ReceiveScreen: View { NavigationStack { Group { if transfers.isEmpty { - ScrollView { emptyState } + emptyState } else { history } @@ -88,11 +88,11 @@ struct ReceiveScreen: View { } private var emptyState: some View { - EmptyStateView( - systemImage: "tray.and.arrow.down", - title: String(localized: "receive_empty_title"), - message: String(localized: "receive_empty_body") - ) { + ContentUnavailableView { + Label(String(localized: "receive_empty_title"), systemImage: "tray.and.arrow.down") + } description: { + Text(LocalizedStringKey("receive_empty_body")) + } actions: { Button(action: model.openAcquisition) { Label(String(localized: "button_receive_files"), systemImage: "plus") } diff --git a/apple/VniDrop/Features/Send/SendModel.swift b/apple/VniDrop/Features/Send/SendModel.swift index 4ca7872..ca96b5d 100644 --- a/apple/VniDrop/Features/Send/SendModel.swift +++ b/apple/VniDrop/Features/Send/SendModel.swift @@ -44,6 +44,12 @@ final class SendModel: ObservableObject { @Published var pendingFilePick = false @Published var pendingFolderPick = false + /// Receiver delivery records per active (sharing) transfer, used to decide + /// which transfers still have an in-flight receiver. Delivery status is the + /// authoritative signal; byte-transfer events alone don't reliably mark a + /// small transfer complete. + @Published private(set) var receiversByTransfer: [UInt64: [ReceiverRequestModel]] = [:] + private let repository: CoreRepository private let fileSystemService: FileSystemService private let filePreviewRepository: FilePreviewRepository @@ -72,10 +78,24 @@ final class SendModel: ObservableObject { Task { _ = await self.repository.refresh() } case .receiverHistoryChanged(let id), .approvalChanged(let id): if id == self.state.selectedTransferId { self.refreshReceivers(id) } + self.refreshReceiverStatuses(for: id) } } .store(in: &cancellables) + // Keep receiver delivery records current for every sharing/importing + // outgoing transfer (new shares appear here; status transitions arrive via + // the receiverHistoryChanged signal above). + repository.$state + .map { core -> Set in + Set(core.transfers + .filter { $0.direction == .send && ($0.status == .sharing || $0.status == .importing) } + .map(\.transferId)) + } + .removeDuplicates() + .sink { [weak self] ids in self?.syncSharingReceivers(ids) } + .store(in: &cancellables) + filePreviewRepository.$previews .sink { [weak self] previews in self?.state.transferThumbnails = previews } .store(in: &cancellables) @@ -299,6 +319,21 @@ final class SendModel: ObservableObject { Task { await fileSystemService.discardPickedFiles(files) } } + /// Refresh the receiver records for the sharing set, pruning transfers that are + /// no longer active. + private func syncSharingReceivers(_ ids: Set) { + receiversByTransfer = receiversByTransfer.filter { ids.contains($0.key) } + for id in ids { refreshReceiverStatuses(for: id) } + } + + private func refreshReceiverStatuses(for transferId: UInt64) { + Task { + if case .success(let requests) = await repository.receiverRequests(transferId: transferId) { + receiversByTransfer[transferId] = requests + } + } + } + private func refreshReceivers(_ transferId: UInt64) { state.isLoadingReceivers = true Task { diff --git a/apple/VniDrop/Features/Send/SendScreen.swift b/apple/VniDrop/Features/Send/SendScreen.swift index cc8669b..368c219 100644 --- a/apple/VniDrop/Features/Send/SendScreen.swift +++ b/apple/VniDrop/Features/Send/SendScreen.swift @@ -22,7 +22,7 @@ struct SendScreen: View { NavigationStack { Group { if outgoing.isEmpty { - ScrollView { emptyState } + emptyState } else { catalog } @@ -100,11 +100,11 @@ struct SendScreen: View { } private var emptyState: some View { - EmptyStateView( - systemImage: "paperplane", - title: String(localized: "send_empty_title"), - message: String(localized: "send_empty_body") - ) { + ContentUnavailableView { + Label(String(localized: "send_empty_title"), systemImage: "paperplane") + } description: { + Text(LocalizedStringKey("send_empty_body")) + } actions: { Button(action: model.openComposer) { Label(String(localized: "button_create_new_transfer"), systemImage: "plus") } @@ -116,10 +116,30 @@ struct SendScreen: View { private func progress(for transfer: Transfer) -> TransferProgress? { switch transfer.status { case .importing: return progressForTransfer(events: model.coreState.events, transferId: transfer.transferId) - case .sharing: return sendProgressSummary(events: model.coreState.events, transferId: transfer.transferId, totalSizeHint: transfer.totalSize) + case .sharing: return sharingProgress(for: transfer) default: return nil } } + + /// Progress for an active share, driven by receivers whose delivery is still + /// in flight (`.accepted`). Returns nil when none are downloading, so the bar + /// clears once every receiver has completed even if byte events lag. + private func sharingProgress(for transfer: Transfer) -> TransferProgress? { + let active = (model.receiversByTransfer[transfer.transferId] ?? []).filter { $0.status == .accepted } + if active.isEmpty { return nil } + let fractions = active.compactMap { + progressForReceiver(events: model.coreState.events, transferId: transfer.transferId, + remoteEndpointId: $0.remoteEndpointId, totalSizeHint: transfer.totalSize)?.progress + } + let combined = fractions.isEmpty ? nil : fractions.reduce(0, +) / Double(fractions.count) + if active.count == 1 { + return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress", + labelKey: "progress_sending", progress: combined) + } + return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress", + labelKey: "progress_sending", progress: combined, + label: String(format: String(localized: "progress_sending_to_count"), active.count)) + } } private struct TransferListItem: View { diff --git a/apple/VniDrop/Features/Settings/SettingsModel.swift b/apple/VniDrop/Features/Settings/SettingsModel.swift index 8055617..b376f82 100644 --- a/apple/VniDrop/Features/Settings/SettingsModel.swift +++ b/apple/VniDrop/Features/Settings/SettingsModel.swift @@ -7,6 +7,7 @@ enum SettingsSection: Hashable { case preferences case appearance case notifications + case storage case about case bugReport @@ -16,12 +17,21 @@ enum SettingsSection: Hashable { case .preferences: return "preferences_title" case .appearance: return "appearance_title" case .notifications: return "notifications_title" + case .storage: return "storage_title" case .about: return "about_title" case .bugReport: return "about_bug_report" } } } +/// On-disk usage breakdown for the Storage screen. +struct StorageBreakdown: Equatable { + var receivedFiles: UInt64 = 0 + var transferData: UInt64 = 0 + var temporary: UInt64 = 0 + var total: UInt64 { receivedFiles + transferData + temporary } +} + struct SettingsState: Equatable { var selectedSection: SettingsSection = .overview var username = "" @@ -43,6 +53,9 @@ struct SettingsState: Equatable { var bugIncludeLogs = true var isSubmittingBugReport = false var bugLogPreviewBytes = 0 + var storage: StorageBreakdown? + var isCalculatingStorage = false + var isDeletingTransfers = false static func == (lhs: SettingsState, rhs: SettingsState) -> Bool { lhs.selectedSection == rhs.selectedSection && lhs.username == rhs.username @@ -57,6 +70,8 @@ struct SettingsState: Equatable { && lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact && lhs.bugIncludeLogs == rhs.bugIncludeLogs && lhs.isSubmittingBugReport == rhs.isSubmittingBugReport && lhs.bugLogPreviewBytes == rhs.bugLogPreviewBytes + && lhs.storage == rhs.storage && lhs.isCalculatingStorage == rhs.isCalculatingStorage + && lhs.isDeletingTransfers == rhs.isDeletingTransfers && lhs.deviceInfo?.operatingSystem == rhs.deviceInfo?.operatingSystem } } @@ -70,6 +85,7 @@ final class SettingsModel: ObservableObject { private let environment: PlatformEnvironment private let deviceInfoProvider: DeviceInfoProvider private let fileSystemService: FileSystemService + private let repository: CoreRepository private let preferences: AppPreferencesRepository private let notifications: LocalNotificationService private let messages: UiMessageController @@ -85,6 +101,7 @@ final class SettingsModel: ObservableObject { environment: PlatformEnvironment, deviceInfoProvider: DeviceInfoProvider, fileSystemService: FileSystemService, + repository: CoreRepository, preferences: AppPreferencesRepository, notifications: LocalNotificationService, messages: UiMessageController, @@ -94,6 +111,7 @@ final class SettingsModel: ObservableObject { self.environment = environment self.deviceInfoProvider = deviceInfoProvider self.fileSystemService = fileSystemService + self.repository = repository self.preferences = preferences self.notifications = notifications self.messages = messages @@ -170,7 +188,7 @@ final class SettingsModel: ObservableObject { text: .resource(key), tone: .warning, actionLabel: permission == .denied ? .resource("button_open_settings") : nil, - onAction: permission == .denied ? { [weak self] in self?.openNotificationSettings() } : nil + onAction: permission == .denied ? { self.openNotificationSettings() } : nil )) } } @@ -193,7 +211,7 @@ final class SettingsModel: ObservableObject { func setBugContact(_ value: String) { state.bugContact = value } func setBugIncludeLogs(_ value: Bool) { state.bugIncludeLogs = value } - func submitBugReport() { + func submitBugReport(onSuccess: @escaping () -> Void = {}) { if state.isSubmittingBugReport { return } Task { let snapshot = state @@ -224,7 +242,7 @@ final class SettingsModel: ObservableObject { state.bugContact = "" state.bugIncludeLogs = true messages.show(UiMessage(text: .resource("bug_report_submitted"), tone: .success)) - selectSection(.about) + onSuccess() case .failure: state.isSubmittingBugReport = false messages.show(UiMessage(text: .resource("bug_report_submit_failed"), tone: .error)) @@ -262,6 +280,60 @@ final class SettingsModel: ObservableObject { messages.show(UiMessage(text: .resource("notifications_enabled_message"), tone: .success)) } + // MARK: - Storage + + /// Recomputes the on-disk usage breakdown off the main actor. + func loadStorageUsage() { + if state.isCalculatingStorage { return } + state.isCalculatingStorage = true + let coreDir = environment.defaultCoreDataDir + let receiveDir = state.receiveFolder?.isFileSystemPath == true ? state.receiveFolder?.value : nil + let tempDir = NSTemporaryDirectory() + Task.detached { + let breakdown = StorageBreakdown( + receivedFiles: receiveDir.map { SettingsModel.directorySize($0) } ?? 0, + transferData: SettingsModel.directorySize(coreDir), + temporary: SettingsModel.directorySize(tempDir) + ) + await MainActor.run { [weak self] in + self?.state.storage = breakdown + self?.state.isCalculatingStorage = false + } + } + } + + /// Deletes every send/receive transfer via the core, freeing the imported + /// shared-file content and clearing history. Node identity and received files + /// are left untouched. + func deleteAllTransfers() { + if state.isDeletingTransfers { return } + state.isDeletingTransfers = true + Task { + for id in repository.state.transfers.map(\.transferId) { + _ = await repository.delete(transferId: id) + } + _ = await repository.refresh() + state.isDeletingTransfers = false + loadStorageUsage() + messages.show(UiMessage(text: .resource("storage_transfers_deleted"), tone: .success)) + } + } + + /// Recursive size of every regular file under `path` (0 if missing). + nonisolated static func directorySize(_ path: String) -> UInt64 { + let url = URL(fileURLWithPath: path) + guard let enumerator = FileManager.default.enumerator( + at: url, includingPropertiesForKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey] + ) else { return 0 } + var total: UInt64 = 0 + for case let fileURL as URL in enumerator { + let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey]) + guard values?.isRegularFile == true else { continue } + total += UInt64(values?.totalFileAllocatedSize ?? values?.fileSize ?? 0) + } + return total + } + private func loadDeviceInfo() { if state.isLoadingDeviceInfo { return } state.isLoadingDeviceInfo = true diff --git a/apple/VniDrop/Features/Settings/SettingsScreen.swift b/apple/VniDrop/Features/Settings/SettingsScreen.swift index bf9d51a..a83d210 100644 --- a/apple/VniDrop/Features/Settings/SettingsScreen.swift +++ b/apple/VniDrop/Features/Settings/SettingsScreen.swift @@ -5,6 +5,7 @@ import SwiftUI struct SettingsScreen: View { @ObservedObject var model: SettingsModel let windowClass: WindowClass + @State private var showBugReport = false private var path: Binding<[SettingsSection]> { Binding( @@ -34,6 +35,9 @@ struct SettingsScreen: View { NavigationLink(value: SettingsSection.notifications) { SettingsRow(icon: "bell", title: String(localized: "notifications_title"), value: nil) } + NavigationLink(value: SettingsSection.storage) { + SettingsRow(icon: "internaldrive", title: String(localized: "storage_title"), value: nil) + } NavigationLink(value: SettingsSection.about) { SettingsRow(icon: "info.circle", title: String(localized: "about_title"), value: nil) } @@ -42,15 +46,41 @@ struct SettingsScreen: View { .formStyle(.grouped) .navigationTitle(Text(LocalizedStringKey("settings_title"))) .navigationDestination(for: SettingsSection.self) { section in - Form { - SettingsSectionContent(model: model, section: section) - } - .formStyle(.grouped) - .navigationTitle(Text(LocalizedStringKey(section.titleKey))) + sectionForm(section) + } + } + } + + @ViewBuilder + private func sectionForm(_ section: SettingsSection) -> some View { + let content = Form { + SettingsSectionContent(model: model, section: section) + } + .formStyle(.grouped) + .navigationTitle(Text(LocalizedStringKey(section.titleKey))) + + if section == .about { + content + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + showBugReport = true + } label: { + Label(String(localized: "about_bug_report"), systemImage: "ladybug") + } + } + } + .sheet(isPresented: $showBugReport) { + BugReportSheet(model: model) + } + } else { + content #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif - } } } } @@ -69,6 +99,8 @@ private struct SettingsSectionContent: View { AppearanceSettings(model: model) case .notifications: NotificationSettings(model: model) + case .storage: + StorageSettings(model: model) case .about: AboutSettings(model: model) case .bugReport: diff --git a/apple/VniDrop/Features/Settings/SettingsSections.swift b/apple/VniDrop/Features/Settings/SettingsSections.swift index d92cbeb..94e5528 100644 --- a/apple/VniDrop/Features/Settings/SettingsSections.swift +++ b/apple/VniDrop/Features/Settings/SettingsSections.swift @@ -57,17 +57,106 @@ struct NotificationSettings: View { } } +struct StorageSettings: View { + @ObservedObject var model: SettingsModel + @State private var showDeleteConfirmation = false + + var body: some View { + Section { + if let storage = model.state.storage { + LabeledContent(String(localized: "storage_received_files"), value: formatBytes(storage.receivedFiles)) + LabeledContent(String(localized: "storage_transfer_data"), value: formatBytes(storage.transferData)) + LabeledContent(String(localized: "storage_temporary"), value: formatBytes(storage.temporary)) + LabeledContent(String(localized: "storage_total")) { + Text(formatBytes(storage.total)).fontWeight(.semibold) + } + } else { + HStack { + Text(LocalizedStringKey("storage_calculating")).foregroundStyle(.secondary) + Spacer() + ProgressView() + } + } + } footer: { + Text(LocalizedStringKey("storage_footer")) + } + + Section { + Button(role: .destructive) { + showDeleteConfirmation = true + } label: { + HStack { + Text(model.state.isDeletingTransfers + ? String(localized: "storage_deleting") + : String(localized: "storage_delete_transfers")) + if model.state.isDeletingTransfers { + Spacer() + ProgressView() + } + } + } + .disabled(model.state.isDeletingTransfers) + } + .onAppear { model.loadStorageUsage() } + .confirmationDialog( + Text(LocalizedStringKey("storage_delete_transfers")), + isPresented: $showDeleteConfirmation, + titleVisibility: .visible + ) { + Button(String(localized: "storage_delete_transfers"), role: .destructive) { + model.deleteAllTransfers() + } + Button(String(localized: "button_cancel"), role: .cancel) {} + } message: { + Text(LocalizedStringKey("storage_delete_transfers_description")) + } + } +} + struct AboutSettings: View { @ObservedObject var model: SettingsModel + private static let sourceURL = URL(string: "https://github.com/vnidrop/vnidrop")! + var body: some View { + Section { + Text(LocalizedStringKey("about_tagline")).font(.headline) + Text(LocalizedStringKey("about_description")).foregroundStyle(.secondary) + } + + Section(String(localized: "about_is_title")) { + AboutPoint("about_is_direct", "paperplane") + AboutPoint("about_is_no_account", "person.crop.circle.badge.xmark") + AboutPoint("about_is_in_control", "checkmark.shield") + AboutPoint("about_is_encrypted", "lock") + AboutPoint("about_is_open", "chevron.left.forwardslash.chevron.right") + } + + Section(String(localized: "about_isnt_title")) { + AboutPoint("about_isnt_cloud", "icloud.slash") + AboutPoint("about_isnt_sync", "arrow.triangle.2.circlepath") + AboutPoint("about_isnt_public", "megaphone") + } + + Section(String(localized: "about_privacy_title")) { + AboutPoint("about_privacy_capability", "qrcode") + AboutPoint("about_privacy_deny", "hand.raised") + AboutPoint("about_privacy_relay", "antenna.radiowaves.left.and.right") + AboutPoint("about_privacy_local", "internaldrive") + } + Section(String(localized: "about_title")) { LabeledContent(String(localized: "version_title"), value: model.state.appVersion) if let device = model.state.deviceInfo { LabeledContent(String(localized: "device_model_title"), value: device.deviceModel ?? "—") LabeledContent(String(localized: "os_version_title"), value: device.operatingSystem) } + LabeledContent(String(localized: "about_license_label"), value: "Apache 2.0") + Link(destination: Self.sourceURL) { + Label(String(localized: "about_source_label"), systemImage: "link") + } } + if DiagnosticsBuildConfig.included { Section { Toggle(isOn: Binding( @@ -78,42 +167,94 @@ struct AboutSettings: View { } } } - Section { - NavigationLink(value: SettingsSection.bugReport) { - Text(LocalizedStringKey("about_bug_report")) + } +} + +/// Bug report presented as a sheet from About. Can be dismissed by swipe only +/// when empty; otherwise the Cancel button is required. +struct BugReportSheet: View { + @ObservedObject var model: SettingsModel + @Environment(\.dismiss) private var dismiss + + private var isEmpty: Bool { + model.state.bugWhatHappened.isEmpty && model.state.bugExpected.isEmpty + && model.state.bugSteps.isEmpty && model.state.bugContact.isEmpty + } + + var body: some View { + NavigationStack { + Form { + BugReportSettings(model: model, onSubmitted: { dismiss() }) } + .formStyle(.grouped) + .navigationTitle(Text(LocalizedStringKey("about_bug_report"))) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "button_cancel")) { dismiss() } + } + } + } + .interactiveDismissDisabled(!isEmpty) + } +} + +/// A bullet-style informational row with an SF Symbol and wrapping localized text. +private struct AboutPoint: View { + let key: String + let symbol: String + + init(_ key: String, _ symbol: String) { + self.key = key + self.symbol = symbol + } + + var body: some View { + Label { + Text(LocalizedStringKey(key)) + .font(.subheadline) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: symbol).foregroundStyle(.tint) } } } struct BugReportSettings: View { @ObservedObject var model: SettingsModel + var onSubmitted: () -> Void = {} var body: some View { Section(String(localized: "bug_report_what_label")) { - TextField(String(localized: "bug_report_what_label"), - text: Binding(get: { model.state.bugWhatHappened }, set: model.setBugWhatHappened), axis: .vertical) + TextField("", text: Binding(get: { model.state.bugWhatHappened }, set: model.setBugWhatHappened), + prompt: Text(LocalizedStringKey("bug_report_what_hint")), axis: .vertical) .lineLimit(3, reservesSpace: true) + .labelsHidden() } Section(String(localized: "bug_report_expected_label")) { - TextField(String(localized: "bug_report_expected_label"), - text: Binding(get: { model.state.bugExpected }, set: model.setBugExpected), axis: .vertical) + TextField("", text: Binding(get: { model.state.bugExpected }, set: model.setBugExpected), + prompt: Text(LocalizedStringKey("bug_report_expected_hint")), axis: .vertical) .lineLimit(3, reservesSpace: true) + .labelsHidden() } Section(String(localized: "bug_report_steps_label")) { - TextField(String(localized: "bug_report_steps_label"), - text: Binding(get: { model.state.bugSteps }, set: model.setBugSteps), axis: .vertical) + TextField("", text: Binding(get: { model.state.bugSteps }, set: model.setBugSteps), + prompt: Text(LocalizedStringKey("bug_report_steps_hint")), axis: .vertical) .lineLimit(3, reservesSpace: true) + .labelsHidden() } Section(String(localized: "bug_report_contact_label")) { - TextField(String(localized: "bug_report_contact_label"), - text: Binding(get: { model.state.bugContact }, set: model.setBugContact)) + TextField("", text: Binding(get: { model.state.bugContact }, set: model.setBugContact), + prompt: Text(LocalizedStringKey("bug_report_contact_hint"))) + .labelsHidden() } Section { Toggle(isOn: Binding(get: { model.state.bugIncludeLogs }, set: { model.setBugIncludeLogs($0) })) { Text(LocalizedStringKey("bug_report_include_logs")) } - Button(action: model.submitBugReport) { + Button(action: { model.submitBugReport(onSuccess: onSubmitted) }) { Text(model.state.isSubmittingBugReport ? String(localized: "bug_report_submitting") : String(localized: "bug_report_submit")) } diff --git a/apple/VniDrop/Resources/Info.plist b/apple/VniDrop/Resources/Info.plist index 28ca33d..f19dd0c 100644 --- a/apple/VniDrop/Resources/Info.plist +++ b/apple/VniDrop/Resources/Info.plist @@ -67,9 +67,10 @@ UISupportedInterfaceOrientations~ipad + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - UIInterfaceOrientationPortrait UIViewControllerBasedStatusBarAppearance diff --git a/apple/VniDrop/Resources/Localizable.xcstrings b/apple/VniDrop/Resources/Localizable.xcstrings index 0387094..7634ae3 100644 --- a/apple/VniDrop/Resources/Localizable.xcstrings +++ b/apple/VniDrop/Resources/Localizable.xcstrings @@ -37,6 +37,196 @@ } } }, + "about_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "VniDrop moves files and folders straight from one device to another over the network, without uploading them to any file-hosting service. There’s no account to create, and no copy of your transfer is left in the cloud once you’re done." + } + } + } + }, + "about_is_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "What VniDrop is" + } + } + } + }, + "about_is_direct" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A direct device-to-device transfer — your files go straight to the receiver." + } + } + } + }, + "about_is_no_account" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Account-free — there’s nothing to sign up for." + } + } + } + }, + "about_is_in_control" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You decide who receives: approve each request, or open a transfer to anyone holding the invitation." + } + } + } + }, + "about_is_encrypted" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connections are authenticated and end-to-end encrypted (via Iroh), and incoming files are verified by their content hash." + } + } + } + }, + "about_is_open" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open source, released under the Apache 2.0 license." + } + } + } + }, + "about_isnt_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "What VniDrop isn’t" + } + } + } + }, + "about_isnt_cloud" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not cloud storage — no server holds your files, and nothing waits in the cloud after a transfer." + } + } + } + }, + "about_isnt_sync" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not a sync or backup service." + } + } + } + }, + "about_isnt_public" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not public broadcasting — an invitation is a private access link, not an announcement to everyone nearby." + } + } + } + }, + "about_privacy_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy & security" + } + } + } + }, + "about_privacy_capability" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invitations are capabilities. Treat a QR code, NFC tag, or .vnd file like a private access link and share it only with the people you intend — especially with “Anyone with this transfer.”" + } + } + } + }, + "about_privacy_deny" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deny by default — VniDrop serves only the content of an active share and rejects unknown requests." + } + } + } + }, + "about_privacy_relay" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "If two devices can’t connect directly, the encrypted connection may be relayed. Relays forward encrypted packets only; they never store your files." + } + } + } + }, + "about_privacy_local" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Received files are saved on your device and never silently overwrite an existing file." + } + } + } + }, + "about_license_label" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "License" + } + } + } + }, + "about_source_label" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Source code" + } + } + } + }, + "about_tagline" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send files directly. Stay in control of who receives them." + } + } + } + }, "about_privacy" : { "extractionState" : "stale", "localizations" : { @@ -170,6 +360,16 @@ } } }, + "bug_report_contact_hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "name@example.com" + } + } + } + }, "bug_report_contact_label" : { "localizations" : { "en" : { @@ -180,6 +380,36 @@ } } }, + "bug_report_expected_hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Describe what you expected to happen" + } + } + } + }, + "bug_report_steps_hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "List the steps to reproduce the issue" + } + } + } + }, + "bug_report_what_hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Describe what happened" + } + } + } + }, "bug_report_description" : { "extractionState" : "stale", "localizations" : { @@ -1729,6 +1959,116 @@ } } }, + "storage_calculating" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calculating…" + } + } + } + }, + "storage_delete_transfers" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete all transfers" + } + } + } + }, + "storage_delete_transfers_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This clears all sent and received transfer records from your history. Your received files are not deleted. Note: the transfer engine keeps its stored content, so Transfer data may not decrease. This can’t be undone." + } + } + } + }, + "storage_deleting" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deleting…" + } + } + } + }, + "storage_footer" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transfer data is managed by the transfer engine — your history plus the content of files you’ve shared. The button below clears your transfer records; the engine keeps its stored content, so this value may not drop. Received files are your downloaded files and are never deleted here." + } + } + } + }, + "storage_received_files" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Received files" + } + } + } + }, + "storage_temporary" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temporary files" + } + } + } + }, + "storage_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Storage" + } + } + } + }, + "storage_total" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total" + } + } + } + }, + "storage_transfer_data" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transfer data" + } + } + } + }, + "storage_transfers_deleted" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All transfers deleted" + } + } + } + }, "status_available" : { "localizations" : { "en" : { diff --git a/apple/VniDrop/UI/Components/Components.swift b/apple/VniDrop/UI/Components/Components.swift index cb83110..7d2b504 100644 --- a/apple/VniDrop/UI/Components/Components.swift +++ b/apple/VniDrop/UI/Components/Components.swift @@ -94,30 +94,3 @@ struct Field: View { } } -// MARK: - EmptyStateView - -/// Native-styled empty state (iOS 16 compatible; avoids iOS 17 -/// `ContentUnavailableView`). -struct EmptyStateView: View { - let systemImage: String - let title: String - let message: String - @ViewBuilder var actions: () -> Actions - - var body: some View { - VStack(spacing: 12) { - Image(systemName: systemImage) - .font(.system(size: 52)) - .foregroundStyle(.secondary) - Text(title).font(.title2).fontWeight(.semibold).multilineTextAlignment(.center) - Text(message) - .font(.body).foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 420) - actions().padding(.top, 4) - } - .frame(maxWidth: .infinity) - .padding(.horizontal, 24) - .padding(.vertical, 48) - } -} diff --git a/apple/scripts/build-core.sh b/apple/scripts/build-core.sh index 828cc8c..77f89c6 100755 --- a/apple/scripts/build-core.sh +++ b/apple/scripts/build-core.sh @@ -33,9 +33,10 @@ GEN_DIR="$PKG_DIR/Sources/VnidropCore" TARGET_DIR="$REPO_ROOT/target" BUILD_DIR="$APPLE_DIR/.build-core" -# Match the iOS deployment target used by the Gobley config in shared/build.gradle.kts. -export IPHONEOS_DEPLOYMENT_TARGET="${IPHONEOS_DEPLOYMENT_TARGET:-16.0}" -export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-13.0}" +# Match the SwiftUI app's deployment targets (apple/project.yml) so the static +# libs are never built for a newer OS than the app links against. +export IPHONEOS_DEPLOYMENT_TARGET="${IPHONEOS_DEPLOYMENT_TARGET:-18.2}" +export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-15.0}" # The workspace `[profile.dev] strip = "debuginfo"` corrupts host proc-macro # dylibs on the current Apple toolchain ("mis-aligned LINKEDIT string pool"),