feat(apple): native SwiftUI app for iOS and macOS

Add a native SwiftUI VniDrop app (Send/Receive/Settings) talking to the
Rust core via generated UniFFI Swift bindings, plus the uniffi-bindgen
helper crate. iOS uses a TabView, macOS a NavigationSplitView sidebar.
This commit is contained in:
2026-07-18 22:06:30 +02:00
parent 601cbc486e
commit ea376a382b
68 changed files with 8650 additions and 1 deletions

View File

@@ -0,0 +1,30 @@
import Foundation
/// Bug-report draft, ported from `diagnostics/BugReportService.kt`.
struct BugReportDraft {
let whatHappened: String
let expected: String
let steps: String
let contact: String
let includeLogs: Bool
}
/// Bug-report submission. The full diagnostics transport (URLSession + build
/// config) lands in the diagnostics phase; this protocol is the stable seam.
protocol BugReportService {
func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result<Void, Error>
func previewLogBytes() async -> Int
}
/// Offline-safe no-op used until the diagnostics transport is configured.
struct NoopBugReportService: BugReportService {
func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result<Void, Error> {
.failure(InvitationError.message("Bug reporting is not configured"))
}
func previewLogBytes() async -> Int { 0 }
}
/// Whether the diagnostics stack is compiled in (mirrors DiagnosticsBuildConfig).
enum DiagnosticsBuildConfig {
static let included = false
}

View File

@@ -0,0 +1,288 @@
import Foundation
import Combine
/// Settings sections, ported from `feature/settings/SettingsViewModel.kt`.
enum SettingsSection: Hashable {
case overview
case preferences
case appearance
case notifications
case about
case bugReport
var titleKey: String {
switch self {
case .overview: return "settings_title"
case .preferences: return "preferences_title"
case .appearance: return "appearance_title"
case .notifications: return "notifications_title"
case .about: return "about_title"
case .bugReport: return "about_bug_report"
}
}
}
struct SettingsState: Equatable {
var selectedSection: SettingsSection = .overview
var username = ""
var receiveFolder: ReceiveFolder?
var folderAccessStatus: FolderAccessStatus = .unavailable
var isValidatingFolder = false
var supportsCustomReceiveFolders = true
var themeMode: ThemeMode = .system
var notificationsEnabled = false
var notificationPermission: NotificationPermission = .notDetermined
var diagnosticsEnabled = false
var deviceInfo: DeviceInfo?
var appVersion = ""
var isLoadingDeviceInfo = false
var bugWhatHappened = ""
var bugExpected = ""
var bugSteps = ""
var bugContact = ""
var bugIncludeLogs = true
var isSubmittingBugReport = false
var bugLogPreviewBytes = 0
static func == (lhs: SettingsState, rhs: SettingsState) -> Bool {
lhs.selectedSection == rhs.selectedSection && lhs.username == rhs.username
&& lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus
&& lhs.isValidatingFolder == rhs.isValidatingFolder
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
&& lhs.themeMode == rhs.themeMode && lhs.notificationsEnabled == rhs.notificationsEnabled
&& lhs.notificationPermission == rhs.notificationPermission
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion
&& lhs.isLoadingDeviceInfo == rhs.isLoadingDeviceInfo
&& lhs.bugWhatHappened == rhs.bugWhatHappened && lhs.bugExpected == rhs.bugExpected
&& lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact
&& lhs.bugIncludeLogs == rhs.bugIncludeLogs && lhs.isSubmittingBugReport == rhs.isSubmittingBugReport
&& lhs.bugLogPreviewBytes == rhs.bugLogPreviewBytes
&& lhs.deviceInfo?.operatingSystem == rhs.deviceInfo?.operatingSystem
}
}
@MainActor
final class SettingsModel: ObservableObject {
@Published private(set) var state: SettingsState
/// Set by the view to request the receive-folder picker (macOS).
@Published var pendingReceiveFolderPick = false
private let environment: PlatformEnvironment
private let deviceInfoProvider: DeviceInfoProvider
private let fileSystemService: FileSystemService
private let preferences: AppPreferencesRepository
private let notifications: LocalNotificationService
private let messages: UiMessageController
private let bugReports: BugReportService
private let diagnosticsIncluded: Bool
private var enableNotificationsAfterSettings = false
private var usernamePersistTask: Task<Void, Never>?
private var hasLocalUsernameDraft = false
private var cancellables = Set<AnyCancellable>()
init(
environment: PlatformEnvironment,
deviceInfoProvider: DeviceInfoProvider,
fileSystemService: FileSystemService,
preferences: AppPreferencesRepository,
notifications: LocalNotificationService,
messages: UiMessageController,
bugReports: BugReportService,
diagnosticsIncluded: Bool = DiagnosticsBuildConfig.included
) {
self.environment = environment
self.deviceInfoProvider = deviceInfoProvider
self.fileSystemService = fileSystemService
self.preferences = preferences
self.notifications = notifications
self.messages = messages
self.bugReports = bugReports
self.diagnosticsIncluded = diagnosticsIncluded
self.state = SettingsState(
supportsCustomReceiveFolders: fileSystemService.supportsCustomReceiveFolders,
appVersion: environment.appVersion
)
preferences.$preferences
.sink { [weak self] prefs in
guard let self else { return }
let previousFolder = self.state.receiveFolder
let folder = self.fileSystemService.effectiveReceiveFolder(prefs.receiveFolder)
self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
self.state.receiveFolder = folder
self.state.themeMode = prefs.themeMode
self.state.notificationsEnabled = prefs.notificationsEnabled
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
if folder != previousFolder { Task { await self.validateFolder(folder) } }
}
.store(in: &cancellables)
refreshNotificationPermission()
loadDeviceInfo()
}
func selectSection(_ section: SettingsSection) {
state.selectedSection = section
if section == .about || section == .bugReport {
loadDeviceInfo()
if section == .bugReport { refreshBugLogPreview() }
}
}
func setUsername(_ value: String) {
hasLocalUsernameDraft = true
state.username = value
usernamePersistTask?.cancel()
usernamePersistTask = Task {
try? await Task.sleep(nanoseconds: 350_000_000)
if Task.isCancelled { return }
preferences.setUsername(value)
}
}
func setThemeMode(_ mode: ThemeMode) { preferences.setThemeMode(mode) }
func chooseReceiveFolder() {
if !fileSystemService.supportsCustomReceiveFolders { return }
pendingReceiveFolderPick = true
}
func onReceiveFolderPicked(_ folder: ReceiveFolder) { preferences.setReceiveFolder(folder) }
func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.message(reason)) }
func resetReceiveFolder() { preferences.resetReceiveFolder() }
func setNotificationsEnabled(_ enabled: Bool) {
Task {
if !enabled {
preferences.setNotificationsEnabled(false)
notifications.cancelAll()
return
}
let permission = await notifications.requestPermission()
state.notificationPermission = permission
if permission == .granted {
await enableNotifications()
} else {
preferences.setNotificationsEnabled(false)
let key = permission == .unsupported ? "notifications_unsupported" : "notifications_permission_denied"
messages.show(UiMessage(
text: .resource(key),
tone: .warning,
actionLabel: permission == .denied ? .resource("button_open_settings") : nil,
onAction: permission == .denied ? { [weak self] in self?.openNotificationSettings() } : nil
))
}
}
}
func setDiagnosticsEnabled(_ enabled: Bool) {
if !diagnosticsIncluded { return }
Task {
preferences.setDiagnosticsEnabled(enabled)
messages.show(UiMessage(
text: .resource(enabled ? "diagnostics_enabled_message" : "diagnostics_disabled_message"),
tone: .success
))
}
}
func setBugWhatHappened(_ value: String) { state.bugWhatHappened = value }
func setBugExpected(_ value: String) { state.bugExpected = value }
func setBugSteps(_ value: String) { state.bugSteps = value }
func setBugContact(_ value: String) { state.bugContact = value }
func setBugIncludeLogs(_ value: Bool) { state.bugIncludeLogs = value }
func submitBugReport() {
if state.isSubmittingBugReport { return }
Task {
let snapshot = state
let what = snapshot.bugWhatHappened.trimmingCharacters(in: .whitespacesAndNewlines)
let expected = snapshot.bugExpected.trimmingCharacters(in: .whitespacesAndNewlines)
if what.isEmpty {
messages.show(UiMessage(text: .resource("bug_report_missing_what"), tone: .warning))
return
}
if expected.isEmpty {
messages.show(UiMessage(text: .resource("bug_report_missing_expected"), tone: .warning))
return
}
state.isSubmittingBugReport = true
let result = await bugReports.submit(
BugReportDraft(
whatHappened: what, expected: expected, steps: snapshot.bugSteps,
contact: snapshot.bugContact, includeLogs: snapshot.bugIncludeLogs
),
deviceInfo: snapshot.deviceInfo
)
switch result {
case .success:
state.isSubmittingBugReport = false
state.bugWhatHappened = ""
state.bugExpected = ""
state.bugSteps = ""
state.bugContact = ""
state.bugIncludeLogs = true
messages.show(UiMessage(text: .resource("bug_report_submitted"), tone: .success))
selectSection(.about)
case .failure:
state.isSubmittingBugReport = false
messages.show(UiMessage(text: .resource("bug_report_submit_failed"), tone: .error))
}
}
}
func openNotificationSettings() {
Task {
enableNotificationsAfterSettings = true
let result = await notifications.openSettings()
if case .failure = result {
enableNotificationsAfterSettings = false
messages.show(UiMessage(text: .resource("notifications_settings_open_failed"), tone: .error))
}
}
}
func refreshNotificationPermission() {
Task {
let permission = await notifications.refreshPermission()
state.notificationPermission = permission
if enableNotificationsAfterSettings {
enableNotificationsAfterSettings = false
if permission == .granted { await enableNotifications() }
} else if permission != .granted && state.notificationsEnabled {
preferences.setNotificationsEnabled(false)
notifications.cancelAll()
}
}
}
private func enableNotifications() async {
preferences.setNotificationsEnabled(true)
messages.show(UiMessage(text: .resource("notifications_enabled_message"), tone: .success))
}
private func loadDeviceInfo() {
if state.isLoadingDeviceInfo { return }
state.isLoadingDeviceInfo = true
Task {
let info = await deviceInfoProvider.load()
state.deviceInfo = info
state.isLoadingDeviceInfo = false
}
}
private func refreshBugLogPreview() {
Task {
let bytes = await bugReports.previewLogBytes()
state.bugLogPreviewBytes = bytes
}
}
private func validateFolder(_ folder: ReceiveFolder) async {
state.isValidatingFolder = true
let status = await fileSystemService.validateReceiveFolder(folder)
state.folderAccessStatus = status
state.isValidatingFolder = false
}
}

View File

@@ -0,0 +1,105 @@
import SwiftUI
/// Settings screen, rebuilt on a native `Form` with `NavigationStack` push
/// navigation. The model stays the source of truth via a derived path binding.
struct SettingsScreen: View {
@ObservedObject var model: SettingsModel
let windowClass: WindowClass
private var path: Binding<[SettingsSection]> {
Binding(
get: {
switch model.state.selectedSection {
case .overview: return []
case .bugReport: return [.about, .bugReport]
case let section: return [section]
}
},
set: { newPath in model.selectSection(newPath.last ?? .overview) }
)
}
var body: some View {
NavigationStack(path: path) {
Form {
Section {
NavigationLink(value: SettingsSection.preferences) {
SettingsRow(icon: "person.crop.circle", title: String(localized: "preferences_title"), value: model.state.username)
}
NavigationLink(value: SettingsSection.appearance) {
SettingsRow(icon: "sun.max", title: String(localized: "appearance_title"), value: themeModeLabel(model.state.themeMode))
}
}
Section {
NavigationLink(value: SettingsSection.notifications) {
SettingsRow(icon: "bell", title: String(localized: "notifications_title"), value: nil)
}
NavigationLink(value: SettingsSection.about) {
SettingsRow(icon: "info.circle", title: String(localized: "about_title"), value: nil)
}
}
}
.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)))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
}
}
private struct SettingsSectionContent: View {
@ObservedObject var model: SettingsModel
let section: SettingsSection
var body: some View {
switch section {
case .overview:
EmptyView()
case .preferences:
PreferencesSettings(model: model)
case .appearance:
AppearanceSettings(model: model)
case .notifications:
NotificationSettings(model: model)
case .about:
AboutSettings(model: model)
case .bugReport:
BugReportSettings(model: model)
}
}
}
struct SettingsRow: View {
let icon: String
let title: String
let value: String?
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.foregroundStyle(.tint)
.frame(width: 26)
Text(title).foregroundStyle(.primary)
Spacer()
if let value {
Text(value).foregroundStyle(.secondary).lineLimit(1)
}
}
}
}
func themeModeLabel(_ mode: ThemeMode) -> String {
switch mode {
case .system: return String(localized: "appearance_system_mode")
case .light: return String(localized: "appearance_light_mode")
case .dark: return String(localized: "appearance_dark_mode")
}
}

View File

@@ -0,0 +1,123 @@
import SwiftUI
/// Settings section detail views, rebuilt as native `Form` content. Each view is
/// placed inside a parent `Form`, so it returns `Section`s / rows directly.
struct PreferencesSettings: View {
@ObservedObject var model: SettingsModel
var body: some View {
Section(String(localized: "field_username")) {
TextField(String(localized: "field_username"),
text: Binding(get: { model.state.username }, set: model.setUsername))
}
if model.state.supportsCustomReceiveFolders {
Section(String(localized: "preferences_receive_folder_title")) {
Text(model.state.receiveFolder?.displayName ?? String(localized: "value_unavailable"))
.foregroundStyle(.secondary)
Button(String(localized: "button_choose_folder"), action: model.chooseReceiveFolder)
Button(String(localized: "button_reset_default"), action: model.resetReceiveFolder)
}
}
}
}
struct AppearanceSettings: View {
@ObservedObject var model: SettingsModel
var body: some View {
Section {
Picker(String(localized: "appearance_title"),
selection: Binding(get: { model.state.themeMode }, set: { model.setThemeMode($0) })) {
ForEach(ThemeMode.allCases, id: \.self) { mode in
Text(themeModeLabel(mode)).tag(mode)
}
}
.pickerStyle(.inline)
.labelsHidden()
}
}
}
struct NotificationSettings: View {
@ObservedObject var model: SettingsModel
var body: some View {
Section {
Toggle(isOn: Binding(
get: { model.state.notificationsEnabled },
set: { model.setNotificationsEnabled($0) }
)) {
Text(LocalizedStringKey("notifications_local_title"))
}
if model.state.notificationPermission == .denied {
Button(String(localized: "button_open_settings"), action: model.openNotificationSettings)
}
}
}
}
struct AboutSettings: View {
@ObservedObject var model: SettingsModel
var body: some View {
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)
}
}
if DiagnosticsBuildConfig.included {
Section {
Toggle(isOn: Binding(
get: { model.state.diagnosticsEnabled },
set: { model.setDiagnosticsEnabled($0) }
)) {
Text(LocalizedStringKey("diagnostics_title"))
}
}
}
Section {
NavigationLink(value: SettingsSection.bugReport) {
Text(LocalizedStringKey("about_bug_report"))
}
}
}
}
struct BugReportSettings: View {
@ObservedObject var model: SettingsModel
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)
.lineLimit(3, reservesSpace: true)
}
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)
.lineLimit(3, reservesSpace: true)
}
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)
.lineLimit(3, reservesSpace: true)
}
Section(String(localized: "bug_report_contact_label")) {
TextField(String(localized: "bug_report_contact_label"),
text: Binding(get: { model.state.bugContact }, set: model.setBugContact))
}
Section {
Toggle(isOn: Binding(get: { model.state.bugIncludeLogs }, set: { model.setBugIncludeLogs($0) })) {
Text(LocalizedStringKey("bug_report_include_logs"))
}
Button(action: model.submitBugReport) {
Text(model.state.isSubmittingBugReport
? String(localized: "bug_report_submitting") : String(localized: "bug_report_submit"))
}
.disabled(model.state.isSubmittingBugReport)
}
}
}