Alpha 4 Update
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
"development": "en",
|
||||
"supported": [
|
||||
"Base",
|
||||
"de",
|
||||
],
|
||||
},
|
||||
"files": [
|
||||
@@ -46,13 +47,14 @@
|
||||
"ENABLE_HARDENED_RUNTIME": "YES",
|
||||
"ENABLE_OUTGOING_NETWORK_CONNECTIONS": "YES",
|
||||
"ENABLE_PREVIEWS": "YES",
|
||||
"ENABLE_USER_SELECTED_FILES": "readonly",
|
||||
"ENABLE_USER_SELECTED_FILES": "readwrite",
|
||||
"GENERATE_INFOPLIST_FILE": "YES",
|
||||
"INFOPLIST_KEY_NSHumanReadableCopyright": "",
|
||||
"LD_RUNPATH_SEARCH_PATHS": [
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
],
|
||||
"MACOSX_DEPLOYMENT_TARGET": "27.0",
|
||||
"MARKETING_VERSION": "1.0",
|
||||
"OTHER_LDFLAGS": [
|
||||
"$(inherited)",
|
||||
@@ -89,7 +91,7 @@
|
||||
"CURRENT_PROJECT_VERSION": "1",
|
||||
"DEVELOPMENT_TEAM": "GQR972L9NJ",
|
||||
"GENERATE_INFOPLIST_FILE": "YES",
|
||||
"MACOSX_DEPLOYMENT_TARGET": "27.2",
|
||||
"MACOSX_DEPLOYMENT_TARGET": "27.0",
|
||||
"MARKETING_VERSION": "1.0",
|
||||
"PRODUCT_BUNDLE_IDENTIFIER": "Dr.-med.-Michael-Ku-bler.MikeMailTests",
|
||||
"PRODUCT_NAME": "$(TARGET_NAME)",
|
||||
@@ -119,6 +121,7 @@
|
||||
"CURRENT_PROJECT_VERSION": "1",
|
||||
"DEVELOPMENT_TEAM": "GQR972L9NJ",
|
||||
"GENERATE_INFOPLIST_FILE": "YES",
|
||||
"MACOSX_DEPLOYMENT_TARGET": "27.0",
|
||||
"MARKETING_VERSION": "1.0",
|
||||
"PRODUCT_BUNDLE_IDENTIFIER": "Dr.-med.-Michael-Ku-bler.MikeMailUITests",
|
||||
"PRODUCT_NAME": "$(TARGET_NAME)",
|
||||
|
||||
MikeMail.xcodeproj/project.xcworkspace/xcuserdata/michael.xcuserdatad/UserInterfaceState.xcuserstate
Generated
BIN
Binary file not shown.
@@ -17,6 +17,7 @@ struct AccountsView: View {
|
||||
@State private var testedPassword = ""
|
||||
@State private var testMailAccount: MailAccount?
|
||||
@State private var askingTestMail = false
|
||||
@State private var accountsNeedingRefresh: Set<UUID> = []
|
||||
@AppStorage("offerTestMail") private var offerTestMail = true
|
||||
|
||||
var body: some View {
|
||||
@@ -116,6 +117,7 @@ struct AccountsView: View {
|
||||
selection = store.accounts.first?.id
|
||||
}
|
||||
.onChange(of: selection) { _, _ in error = nil; resetResult = nil }
|
||||
.onDisappear { refreshChangedAccounts() }
|
||||
.confirmationDialog("Alle lokalen Mails dieses Kontos löschen?", isPresented: $confirmingReset, titleVisibility: .visible) {
|
||||
Button("Lokale Mails endgültig löschen", role: .destructive) {
|
||||
guard let account = resetAccount else { return }
|
||||
@@ -153,11 +155,25 @@ struct AccountsView: View {
|
||||
await store.refresh(folderID: nil, accountID: id)
|
||||
if offerTestMail { testMailAccount = account; askingTestMail = true }
|
||||
}
|
||||
} else {
|
||||
accountsNeedingRefresh.insert(id)
|
||||
}
|
||||
error = nil
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
private func refreshChangedAccounts() {
|
||||
let accountIDs = accountsNeedingRefresh
|
||||
accountsNeedingRefresh.removeAll()
|
||||
guard !accountIDs.isEmpty else { return }
|
||||
Task {
|
||||
for id in accountIDs where store.accounts.contains(where: { $0.id == id }) {
|
||||
while store.isBusy { try? await Task.sleep(for: .milliseconds(150)) }
|
||||
await store.refresh(folderID: nil, accountID: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func testConnection() {
|
||||
guard let id = selection, let account = drafts[id] else { return }
|
||||
let password = passwords[id] ?? ""
|
||||
@@ -190,6 +206,7 @@ struct AccountsView: View {
|
||||
struct AccountSettingsForm: View {
|
||||
@Binding var account: MailAccount
|
||||
@Binding var password: String
|
||||
@State private var showsPassword = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
@@ -199,7 +216,21 @@ struct AccountSettingsForm: View {
|
||||
TextField("Server", text: $account.host)
|
||||
TextField("IMAP-Port (TLS)", value: $account.imapPort, format: .number.grouping(.never))
|
||||
TextField("SMTP-Port (587 STARTTLS / 465 TLS)", value: $account.smtpPort, format: .number.grouping(.never))
|
||||
SecureField("Passwort", text: $password)
|
||||
LabeledContent("Passwort") {
|
||||
HStack(spacing: 8) {
|
||||
if showsPassword {
|
||||
TextField("Passwort", text: $password)
|
||||
} else {
|
||||
SecureField("Passwort", text: $password)
|
||||
}
|
||||
Button(showsPassword ? "Passwort verbergen" : "Passwort anzeigen",
|
||||
systemImage: showsPassword ? "eye.slash" : "eye") {
|
||||
showsPassword.toggle()
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
-12
@@ -1,7 +1,30 @@
|
||||
import SwiftUI
|
||||
import Observation
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@MainActor @Observable
|
||||
final class AccountPresentation {
|
||||
var isPresented = false
|
||||
}
|
||||
|
||||
extension FocusedValues {
|
||||
@Entry var accountPresentation: AccountPresentation?
|
||||
}
|
||||
|
||||
struct AccountCommands: Commands {
|
||||
@FocusedValue(\.accountPresentation) private var accountPresentation
|
||||
|
||||
var body: some Commands {
|
||||
CommandGroup(after: .appSettings) {
|
||||
Button("Konten verwalten …") {
|
||||
accountPresentation?.isPresented = true
|
||||
}
|
||||
.disabled(accountPresentation == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
let store: MailStore
|
||||
@AppStorage("selectedFolder") private var savedFolder = ""
|
||||
@@ -16,7 +39,7 @@ struct ContentView: View {
|
||||
@AppStorage("searchRegex") private var searchRegex = false
|
||||
@State private var selectedFolder: String?
|
||||
@State private var selectedMessages: Set<String> = []
|
||||
@State private var showingAccounts = false
|
||||
@State private var accountPresentation = AccountPresentation()
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@AppStorage("idleEnabled") private var idleEnabled = true
|
||||
@AppStorage("pollMinutes") private var pollMinutes = 5
|
||||
@@ -119,11 +142,14 @@ struct ContentView: View {
|
||||
.keyboardShortcut("n", modifiers: .command).disabled(store.accounts.isEmpty)
|
||||
Button("Löschen", systemImage: "trash") { deleteSelection() }
|
||||
.disabled(selectedMessages.isEmpty)
|
||||
Button("Konten", systemImage: "person.crop.circle") { showingAccounts = true }
|
||||
SettingsLink { Label("Einstellungen", systemImage: "gearshape") }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAccounts) { AccountsView(store: store) }
|
||||
.focusedSceneValue(\.accountPresentation, accountPresentation)
|
||||
.sheet(isPresented: Binding(
|
||||
get: { accountPresentation.isPresented },
|
||||
set: { accountPresentation.isPresented = $0 }
|
||||
)) { AccountsView(store: store) }
|
||||
.sheet(item: Binding(get: { store.authenticationAccount }, set: { value in
|
||||
if value == nil { store.dismissPasswordRequest() }
|
||||
})) { account in
|
||||
@@ -307,24 +333,37 @@ struct MessageTable: View {
|
||||
@AppStorage("mailSortColumn") private var savedColumn = "date"
|
||||
@AppStorage("mailSortAscending") private var savedAscending = false
|
||||
@State private var sortOrder = [KeyPathComparator(\MailMessage.date, order: .reverse)]
|
||||
@SceneStorage("MessageTableColumns")
|
||||
private var columnCustomization: TableColumnCustomization<MailMessage>
|
||||
|
||||
var body: some View {
|
||||
Table(of: MailMessage.self, selection: $selection, sortOrder: $sortOrder) {
|
||||
TableColumn("Anhang", value: \.attachmentCount) { message in
|
||||
if !message.attachments.isEmpty {
|
||||
Image(systemName: "paperclip").accessibilityLabel(message.attachments.count == 1 ? "Ein Anhang" : "\(message.attachments.count) Anhänge")
|
||||
}
|
||||
}.width(55)
|
||||
Table(of: MailMessage.self, selection: $selection, sortOrder: $sortOrder,
|
||||
columnCustomization: $columnCustomization) {
|
||||
TableColumn("Absender", value: \.displaySender) { message in
|
||||
Text(message.displaySender).fontWeight(message.isRead ? .regular : .bold)
|
||||
}.width(min: 110, ideal: 160)
|
||||
}
|
||||
.width(min: 110, ideal: 160)
|
||||
.customizationID("sender")
|
||||
TableColumn("", value: \.attachmentCount) { message in
|
||||
if !message.attachments.isEmpty {
|
||||
Image(systemName: "paperclip")
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.accessibilityLabel(message.attachments.count == 1 ? "Ein Anhang" : "\(message.attachments.count) Anhänge")
|
||||
}
|
||||
}
|
||||
.width(28)
|
||||
.customizationID("attachments")
|
||||
TableColumn("Datum", value: \.date) { message in
|
||||
if message.date == .distantPast { Text("—") }
|
||||
else { Text(MailDisplay.date(message.date)).monospacedDigit().fontWeight(message.isRead ? .regular : .bold) }
|
||||
}.width(min: 120, ideal: 150)
|
||||
}
|
||||
.width(min: 120, ideal: 150)
|
||||
.customizationID("date")
|
||||
TableColumn("Betreff", value: \.subject) { message in
|
||||
Text(message.subject).fontWeight(message.isRead ? .regular : .bold)
|
||||
}.width(min: 160, ideal: 280)
|
||||
}
|
||||
.width(min: 160, ideal: 280)
|
||||
.customizationID("subject")
|
||||
} rows: {
|
||||
ForEach(messages.sorted(using: sortOrder)) { message in
|
||||
TableRow(message).draggable(MailDrag.encode(selection.contains(message.id) ? selection : [message.id]))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -108,7 +108,7 @@ enum PasswordStore {
|
||||
let status = SecItemCopyMatching(entry as CFDictionary, &result)
|
||||
guard status == errSecSuccess, let data = result as? Data,
|
||||
let value = String(data: data, encoding: .utf8) else {
|
||||
throw MailError.message("Bitte unter „Konten“ das Passwort für dieses Konto speichern.")
|
||||
throw MailError.authentication("Für dieses Konto fehlt ein gültiges Passwort im Schlüsselbund.")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
+230
-31
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum MailDeleteConfirmation: String {
|
||||
case always, multiple, never
|
||||
@@ -25,6 +26,11 @@ struct AutoReadRequest: Equatable {
|
||||
}
|
||||
|
||||
struct MailSettings: View {
|
||||
let store: MailStore
|
||||
@State private var includePasswordsInBackup = false
|
||||
@State private var restoreData: Data?
|
||||
@State private var confirmingRestore = false
|
||||
@State private var backupStatus = UserDefaults.standard.string(forKey: SettingsBackupManager.restoreResultKey)
|
||||
@AppStorage("markReadImmediately") private var markReadImmediately = false
|
||||
@AppStorage("markReadDelaySeconds") private var markReadDelaySeconds = 1
|
||||
@AppStorage("bodyFontName") private var bodyFontName = "Helvetica"
|
||||
@@ -39,44 +45,237 @@ struct MailSettings: View {
|
||||
@AppStorage("showUnifiedInbox") private var showInbox = true
|
||||
@AppStorage("showUnifiedTrash") private var showTrash = true
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
Tab("Darstellung", systemImage: "textformat") {
|
||||
DisplaySettingsTab(
|
||||
bodyFontName: $bodyFontName,
|
||||
bodyFontSize: $bodyFontSize,
|
||||
blockRemoteContent: $blockRemoteContent,
|
||||
showInbox: $showInbox,
|
||||
showTrash: $showTrash,
|
||||
fontNames: fontNames
|
||||
)
|
||||
}
|
||||
Tab("Abruf", systemImage: "arrow.clockwise") {
|
||||
RetrievalSettingsTab(
|
||||
idleEnabled: $idleEnabled,
|
||||
pollMinutes: $pollMinutes,
|
||||
offerTestMail: $offerTestMail,
|
||||
checkOnLaunch: $checkOnLaunch
|
||||
)
|
||||
}
|
||||
Tab("Verhalten", systemImage: "slider.horizontal.3") {
|
||||
BehaviorSettingsTab(
|
||||
markReadImmediately: $markReadImmediately,
|
||||
markReadDelaySeconds: $markReadDelaySeconds,
|
||||
deleteConfirmation: $deleteConfirmation
|
||||
)
|
||||
}
|
||||
Tab("Sicherung", systemImage: "externaldrive") {
|
||||
BackupSettingsTab(
|
||||
includePasswords: $includePasswordsInBackup,
|
||||
status: backupStatus,
|
||||
save: saveSettingsBackup,
|
||||
restore: selectSettingsBackup
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 940)
|
||||
.frame(minHeight: 620)
|
||||
.onAppear {
|
||||
if backupStatus != nil {
|
||||
UserDefaults.standard.removeObject(forKey: SettingsBackupManager.restoreResultKey)
|
||||
}
|
||||
}
|
||||
.alert("Einstellungen wiederherstellen?", isPresented: $confirmingRestore) {
|
||||
Button("Abbrechen", role: .cancel) { restoreData = nil }
|
||||
Button("Beim nächsten Start wiederherstellen", role: .destructive) {
|
||||
stageSettingsRestore()
|
||||
}
|
||||
} message: {
|
||||
Text("Die aktuellen Einstellungen und Konten werden beim nächsten Start ersetzt. Mails und Anhänge bleiben unverändert. MikeMail wird jetzt automatisch beendet; öffne die App anschließend erneut.")
|
||||
}
|
||||
}
|
||||
|
||||
private func saveSettingsBackup() {
|
||||
do {
|
||||
let backup = try SettingsBackupManager.create(
|
||||
accounts: store.accounts,
|
||||
includePasswords: includePasswordsInBackup
|
||||
)
|
||||
let panel = NSSavePanel()
|
||||
panel.allowedContentTypes = [.json]
|
||||
panel.nameFieldStringValue = "MikeMail-Einstellungen.json"
|
||||
panel.title = "Einstellungen sichern"
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||
try backup.data.write(to: url, options: .atomic)
|
||||
backupStatus = backup.missingPasswords == 0
|
||||
? "Einstellungen und \(store.accounts.count) Konten wurden gesichert."
|
||||
: "Backup gespeichert. Für \(backup.missingPasswords) Konten war kein Passwort im Schlüsselbund vorhanden."
|
||||
} catch {
|
||||
backupStatus = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func selectSettingsBackup() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.json]
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canChooseDirectories = false
|
||||
panel.title = "Einstellungen wiederherstellen"
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
_ = try SettingsBackupManager.validate(data)
|
||||
restoreData = data
|
||||
confirmingRestore = true
|
||||
} catch {
|
||||
backupStatus = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func stageSettingsRestore() {
|
||||
guard let restoreData else { return }
|
||||
do {
|
||||
try SettingsBackupManager.stageRestore(restoreData)
|
||||
backupStatus = "Backup geprüft. MikeMail wird beendet und stellt die Einstellungen beim nächsten Start wieder her."
|
||||
NSApp.terminate(nil)
|
||||
} catch {
|
||||
backupStatus = error.localizedDescription
|
||||
}
|
||||
self.restoreData = nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct DisplaySettingsTab: View {
|
||||
@Binding var bodyFontName: String
|
||||
@Binding var bodyFontSize: Double
|
||||
@Binding var blockRemoteContent: Bool
|
||||
@Binding var showInbox: Bool
|
||||
@Binding var showTrash: Bool
|
||||
let fontNames: [String]
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Picker("Schriftart für Mailtexte", selection: $bodyFontName) {
|
||||
ForEach(fontNames, id: \.self) { Text($0).tag($0) }
|
||||
Section("Maildarstellung") {
|
||||
Picker("Schriftart für Mailtexte", selection: $bodyFontName) {
|
||||
ForEach(fontNames, id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
Stepper("Schriftgröße: \(Int(bodyFontSize)) pt", value: $bodyFontSize, in: 8...48)
|
||||
Text("Vorschau Mailtext")
|
||||
.font(.custom(bodyFontName, size: bodyFontSize))
|
||||
.padding(.top, 8)
|
||||
Toggle("Externe Inhalte in HTML-Mails blockieren", isOn: $blockRemoteContent)
|
||||
Text("Gilt beim Öffnen einer Mail. Inhalte können für eine einzelne Mail freigegeben werden. Eigene HTML-Schriftformate bleiben erhalten.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Stepper("Schriftgröße: \(Int(bodyFontSize)) pt", value: $bodyFontSize, in: 8...48)
|
||||
Text("Vorschau Mailtext").font(.custom(bodyFontName, size: bodyFontSize))
|
||||
Toggle("Externe Inhalte in HTML-Mails blockieren", isOn: $blockRemoteContent)
|
||||
Text("Gilt beim Öffnen einer Mail. Inhalte können für eine einzelne Mail freigegeben werden. Eigene HTML-Schriftformate bleiben erhalten.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Divider()
|
||||
Toggle("Sofortempfang mit IMAP IDLE", isOn: $idleEnabled)
|
||||
Text("Neue Mails werden bei laufender App sofort gemeldet. Das Prüfintervall dient zusätzlich als Absicherung.").font(.caption).foregroundStyle(.secondary)
|
||||
Picker("Auf neue Mails prüfen", selection: $pollMinutes) {
|
||||
Text("Nur manuell").tag(0)
|
||||
ForEach([1, 2, 5, 10, 15, 30, 60], id: \.self) { Text("Alle \($0) Minuten").tag($0) }
|
||||
Section("Seitenleiste") {
|
||||
Toggle("Gemeinsamen Posteingang anzeigen", isOn: $showInbox)
|
||||
Toggle("Gemeinsamen Papierkorb anzeigen", isOn: $showTrash)
|
||||
}
|
||||
Toggle("Nach neuem Konto Testmail anbieten", isOn: $offerTestMail)
|
||||
Toggle("Beim Start automatisch auf neue Mails prüfen", isOn: $checkOnLaunch)
|
||||
Toggle("Gemeinsamen Posteingang anzeigen", isOn: $showInbox)
|
||||
Toggle("Gemeinsamen Papierkorb anzeigen", isOn: $showTrash)
|
||||
Picker("Als gelesen markieren", selection: $markReadImmediately) {
|
||||
Text("Sofort bei Auswahl").tag(true)
|
||||
Text("Nach einer Wartezeit").tag(false)
|
||||
}
|
||||
if !markReadImmediately {
|
||||
Stepper("Nach \(markReadDelaySeconds) Sekunden", value: $markReadDelaySeconds, in: 1...3600)
|
||||
TextField("Wartezeit in Sekunden", value: $markReadDelaySeconds, format: .number)
|
||||
.onChange(of: markReadDelaySeconds) { _, value in
|
||||
markReadDelaySeconds = min(3600, max(1, value))
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RetrievalSettingsTab: View {
|
||||
@Binding var idleEnabled: Bool
|
||||
@Binding var pollMinutes: Int
|
||||
@Binding var offerTestMail: Bool
|
||||
@Binding var checkOnLaunch: Bool
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Mailabruf") {
|
||||
Toggle("Sofortempfang mit IMAP IDLE", isOn: $idleEnabled)
|
||||
Text("Neue Mails werden bei laufender App sofort gemeldet. Das Prüfintervall dient zusätzlich als Absicherung.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Picker("Auf neue Mails prüfen", selection: $pollMinutes) {
|
||||
Text("Nur manuell").tag(0)
|
||||
ForEach([1, 2, 5, 10, 15, 30, 60], id: \.self) {
|
||||
Text("Alle \($0) Minuten").tag($0)
|
||||
}
|
||||
}
|
||||
Toggle("Beim Start automatisch auf neue Mails prüfen", isOn: $checkOnLaunch)
|
||||
}
|
||||
Picker("Beim Löschen von Mails nachfragen", selection: $deleteConfirmation) {
|
||||
Text("Immer").tag(MailDeleteConfirmation.always)
|
||||
Text("Nur bei mehreren Nachrichten").tag(MailDeleteConfirmation.multiple)
|
||||
Text("Nie").tag(MailDeleteConfirmation.never)
|
||||
Section("Neue Konten") {
|
||||
Toggle("Nach neuem Konto Testmail anbieten", isOn: $offerTestMail)
|
||||
}
|
||||
}.padding(24).frame(width: 460)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
|
||||
private struct BehaviorSettingsTab: View {
|
||||
@Binding var markReadImmediately: Bool
|
||||
@Binding var markReadDelaySeconds: Int
|
||||
@Binding var deleteConfirmation: MailDeleteConfirmation
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Lesestatus") {
|
||||
Picker("Als gelesen markieren", selection: $markReadImmediately) {
|
||||
Text("Sofort bei Auswahl").tag(true)
|
||||
Text("Nach einer Wartezeit").tag(false)
|
||||
}
|
||||
if !markReadImmediately {
|
||||
Stepper("Nach \(markReadDelaySeconds) Sekunden", value: $markReadDelaySeconds, in: 1...3600)
|
||||
}
|
||||
}
|
||||
Section("Löschen") {
|
||||
Picker("Beim Löschen von Mails nachfragen", selection: $deleteConfirmation) {
|
||||
Text("Immer").tag(MailDeleteConfirmation.always)
|
||||
Text("Nur bei mehreren Nachrichten").tag(MailDeleteConfirmation.multiple)
|
||||
Text("Nie").tag(MailDeleteConfirmation.never)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
|
||||
private struct BackupSettingsTab: View {
|
||||
@Binding var includePasswords: Bool
|
||||
let status: String?
|
||||
let save: () -> Void
|
||||
let restore: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Einstellungen und Konten") {
|
||||
Text("Das Backup enthält App-Einstellungen, Konten sowie gespeicherte Fenster-, Split- und Tabellenlayouts. Mails und Anhänge werden nicht gesichert.")
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Toggle("Passwörter in das Backup aufnehmen", isOn: $includePasswords)
|
||||
if includePasswords {
|
||||
Text("Das Backup enthält die Passwörter im Klartext. Bewahre die Datei an einem geschützten Ort auf.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
HStack {
|
||||
Button("Einstellungen sichern …", systemImage: "square.and.arrow.up", action: save)
|
||||
Button("Einstellungen wiederherstellen …", systemImage: "square.and.arrow.down", action: restore)
|
||||
}
|
||||
if let status {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -313,7 +313,11 @@ final class MailStore {
|
||||
do {
|
||||
try await client.login(account: account, password: PasswordStore.password(for: id))
|
||||
return client
|
||||
} catch { client.close(); throw error }
|
||||
} catch {
|
||||
client.close()
|
||||
if case MailError.authentication = error { requestPassword(accountID: id) }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Message mutations commit their confirmed changes locally; folder operations rediscover the tree.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"CFBundleName" : {
|
||||
"comment" : "Bundle name",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "machine_translated",
|
||||
"value" : "MikeMail"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "MikeMail"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSHumanReadableCopyright" : {
|
||||
"comment" : "Copyright (human-readable)",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "machine_translated",
|
||||
"value" : ""
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.3"
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
@main
|
||||
struct MikeMailApp: App {
|
||||
@State private var store: MailStore = {
|
||||
SettingsBackupManager.applyPendingRestoreIfNeeded()
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.arguments.contains("--ui-fixture") { return MailStore.uiFixture() }
|
||||
#endif
|
||||
@@ -14,6 +15,7 @@ struct MikeMailApp: App {
|
||||
ContentView(store: store)
|
||||
}
|
||||
.defaultSize(width: 1200, height: 800)
|
||||
.commands { AccountCommands() }
|
||||
WindowGroup("Nachricht", id: "message", for: String.self) { $id in
|
||||
MessageDetailWindow(store: store, messageID: id)
|
||||
}.defaultSize(width: 800, height: 650)
|
||||
@@ -24,6 +26,6 @@ struct MikeMailApp: App {
|
||||
}
|
||||
.defaultSize(width: 760, height: 780)
|
||||
.windowResizability(.contentMinSize)
|
||||
Settings { MailSettings() }
|
||||
Settings { MailSettings(store: store) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ struct PasswordRecoveryView: View {
|
||||
let account: MailAccount
|
||||
@State private var password = ""
|
||||
@State private var checking = false
|
||||
@State private var showsPassword = false
|
||||
@State private var error: String?
|
||||
@FocusState private var passwordFocused: Bool
|
||||
|
||||
@@ -12,9 +13,23 @@ struct PasswordRecoveryView: View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Passwort erforderlich").font(.title2).bold()
|
||||
Text("Der Mailserver hat die Anmeldung für \(account.email) abgelehnt.")
|
||||
SecureField("Neues Passwort", text: $password)
|
||||
.textContentType(.password)
|
||||
.focused($passwordFocused)
|
||||
HStack(spacing: 8) {
|
||||
if showsPassword {
|
||||
TextField("Neues Passwort", text: $password)
|
||||
.textContentType(.password)
|
||||
.focused($passwordFocused)
|
||||
} else {
|
||||
SecureField("Neues Passwort", text: $password)
|
||||
.textContentType(.password)
|
||||
.focused($passwordFocused)
|
||||
}
|
||||
Button(showsPassword ? "Passwort verbergen" : "Passwort anzeigen",
|
||||
systemImage: showsPassword ? "eye.slash" : "eye") {
|
||||
showsPassword.toggle()
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
Text("Das Passwort wird erst nach erfolgreicher Anmeldung im Schlüsselbund gespeichert.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if let error { Text(error).foregroundStyle(.red) }
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
|
||||
struct SettingsBackup: Codable {
|
||||
let formatVersion: Int
|
||||
let createdAt: Date
|
||||
let preferences: Data
|
||||
let accounts: [MailAccount]
|
||||
let passwords: [UUID: String]?
|
||||
}
|
||||
|
||||
enum SettingsBackupManager {
|
||||
private static let formatVersion = 1
|
||||
private static let pendingRestoreKey = "MikeMail.PendingSettingsRestore"
|
||||
static let restoreResultKey = "MikeMail.SettingsRestoreResult"
|
||||
|
||||
static func create(accounts: [MailAccount], includePasswords: Bool) throws -> (data: Data, missingPasswords: Int) {
|
||||
guard let bundleIdentifier = Bundle.main.bundleIdentifier else {
|
||||
throw MailError.message("Die App-Einstellungen konnten nicht ermittelt werden.")
|
||||
}
|
||||
|
||||
var preferences = UserDefaults.standard.persistentDomain(forName: bundleIdentifier) ?? [:]
|
||||
preferences[pendingRestoreKey] = nil
|
||||
preferences[restoreResultKey] = nil
|
||||
let preferencesData = try PropertyListSerialization.data(
|
||||
fromPropertyList: preferences,
|
||||
format: .binary,
|
||||
options: 0
|
||||
)
|
||||
|
||||
var passwords: [UUID: String]?
|
||||
var missingPasswords = 0
|
||||
if includePasswords {
|
||||
var storedPasswords: [UUID: String] = [:]
|
||||
for account in accounts {
|
||||
do {
|
||||
storedPasswords[account.id] = try PasswordStore.password(for: account.id)
|
||||
} catch {
|
||||
missingPasswords += 1
|
||||
}
|
||||
}
|
||||
passwords = storedPasswords
|
||||
}
|
||||
|
||||
let backup = SettingsBackup(
|
||||
formatVersion: formatVersion,
|
||||
createdAt: Date(),
|
||||
preferences: preferencesData,
|
||||
accounts: accounts,
|
||||
passwords: passwords
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
return (try encoder.encode(backup), missingPasswords)
|
||||
}
|
||||
|
||||
static func validate(_ data: Data) throws -> SettingsBackup {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let backup: SettingsBackup
|
||||
do {
|
||||
backup = try decoder.decode(SettingsBackup.self, from: data)
|
||||
} catch {
|
||||
throw MailError.message("Die ausgewählte Datei ist kein gültiges MikeMail-Einstellungsbackup.")
|
||||
}
|
||||
guard backup.formatVersion == formatVersion else {
|
||||
throw MailError.message("Diese Backup-Version wird nicht unterstützt.")
|
||||
}
|
||||
guard (try PropertyListSerialization.propertyList(from: backup.preferences, options: [], format: nil)) is [String: Any] else {
|
||||
throw MailError.message("Das Backup enthält keine gültigen App-Einstellungen.")
|
||||
}
|
||||
return backup
|
||||
}
|
||||
|
||||
static func stageRestore(_ data: Data) throws {
|
||||
_ = try validate(data)
|
||||
UserDefaults.standard.set(data, forKey: pendingRestoreKey)
|
||||
}
|
||||
|
||||
static func applyPendingRestoreIfNeeded() {
|
||||
let defaults = UserDefaults.standard
|
||||
guard let data = defaults.data(forKey: pendingRestoreKey) else { return }
|
||||
|
||||
do {
|
||||
let backup = try validate(data)
|
||||
guard let bundleIdentifier = Bundle.main.bundleIdentifier,
|
||||
var preferences = try PropertyListSerialization.propertyList(
|
||||
from: backup.preferences,
|
||||
options: [],
|
||||
format: nil
|
||||
) as? [String: Any] else {
|
||||
throw MailError.message("Die App-Einstellungen im Backup sind ungültig.")
|
||||
}
|
||||
|
||||
preferences[pendingRestoreKey] = nil
|
||||
preferences[restoreResultKey] = nil
|
||||
defaults.setPersistentDomain(preferences, forName: bundleIdentifier)
|
||||
|
||||
let archive = try MailArchive()
|
||||
let current = try archive.configuration()
|
||||
let restoredAccountIDs = Set(backup.accounts.map(\.id))
|
||||
let retainedFolders = (current?.folders ?? [:]).filter { restoredAccountIDs.contains($0.key) }
|
||||
try archive.saveConfiguration(ArchiveConfiguration(
|
||||
accounts: backup.accounts,
|
||||
folders: retainedFolders,
|
||||
recipients: current?.recipients ?? []
|
||||
))
|
||||
|
||||
for (accountID, password) in backup.passwords ?? [:] {
|
||||
try PasswordStore.save(password, for: accountID)
|
||||
}
|
||||
|
||||
defaults.set(
|
||||
"Einstellungen und \(backup.accounts.count) Konten wurden wiederhergestellt.",
|
||||
forKey: restoreResultKey
|
||||
)
|
||||
} catch {
|
||||
defaults.removeObject(forKey: pendingRestoreKey)
|
||||
defaults.set(
|
||||
"Wiederherstellung fehlgeschlagen: \(error.localizedDescription)",
|
||||
forKey: restoreResultKey
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user