Alpha 4 Update

This commit is contained in:
2026-11-08 19:20:53 +01:00
committed by michael300679
parent c5e46946ac
commit 01c3c20527
12 changed files with 2147 additions and 52 deletions
+5 -2
View File
@@ -8,6 +8,7 @@
"development": "en", "development": "en",
"supported": [ "supported": [
"Base", "Base",
"de",
], ],
}, },
"files": [ "files": [
@@ -46,13 +47,14 @@
"ENABLE_HARDENED_RUNTIME": "YES", "ENABLE_HARDENED_RUNTIME": "YES",
"ENABLE_OUTGOING_NETWORK_CONNECTIONS": "YES", "ENABLE_OUTGOING_NETWORK_CONNECTIONS": "YES",
"ENABLE_PREVIEWS": "YES", "ENABLE_PREVIEWS": "YES",
"ENABLE_USER_SELECTED_FILES": "readonly", "ENABLE_USER_SELECTED_FILES": "readwrite",
"GENERATE_INFOPLIST_FILE": "YES", "GENERATE_INFOPLIST_FILE": "YES",
"INFOPLIST_KEY_NSHumanReadableCopyright": "", "INFOPLIST_KEY_NSHumanReadableCopyright": "",
"LD_RUNPATH_SEARCH_PATHS": [ "LD_RUNPATH_SEARCH_PATHS": [
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
], ],
"MACOSX_DEPLOYMENT_TARGET": "27.0",
"MARKETING_VERSION": "1.0", "MARKETING_VERSION": "1.0",
"OTHER_LDFLAGS": [ "OTHER_LDFLAGS": [
"$(inherited)", "$(inherited)",
@@ -89,7 +91,7 @@
"CURRENT_PROJECT_VERSION": "1", "CURRENT_PROJECT_VERSION": "1",
"DEVELOPMENT_TEAM": "GQR972L9NJ", "DEVELOPMENT_TEAM": "GQR972L9NJ",
"GENERATE_INFOPLIST_FILE": "YES", "GENERATE_INFOPLIST_FILE": "YES",
"MACOSX_DEPLOYMENT_TARGET": "27.2", "MACOSX_DEPLOYMENT_TARGET": "27.0",
"MARKETING_VERSION": "1.0", "MARKETING_VERSION": "1.0",
"PRODUCT_BUNDLE_IDENTIFIER": "Dr.-med.-Michael-Ku-bler.MikeMailTests", "PRODUCT_BUNDLE_IDENTIFIER": "Dr.-med.-Michael-Ku-bler.MikeMailTests",
"PRODUCT_NAME": "$(TARGET_NAME)", "PRODUCT_NAME": "$(TARGET_NAME)",
@@ -119,6 +121,7 @@
"CURRENT_PROJECT_VERSION": "1", "CURRENT_PROJECT_VERSION": "1",
"DEVELOPMENT_TEAM": "GQR972L9NJ", "DEVELOPMENT_TEAM": "GQR972L9NJ",
"GENERATE_INFOPLIST_FILE": "YES", "GENERATE_INFOPLIST_FILE": "YES",
"MACOSX_DEPLOYMENT_TARGET": "27.0",
"MARKETING_VERSION": "1.0", "MARKETING_VERSION": "1.0",
"PRODUCT_BUNDLE_IDENTIFIER": "Dr.-med.-Michael-Ku-bler.MikeMailUITests", "PRODUCT_BUNDLE_IDENTIFIER": "Dr.-med.-Michael-Ku-bler.MikeMailUITests",
"PRODUCT_NAME": "$(TARGET_NAME)", "PRODUCT_NAME": "$(TARGET_NAME)",
+32 -1
View File
@@ -17,6 +17,7 @@ struct AccountsView: View {
@State private var testedPassword = "" @State private var testedPassword = ""
@State private var testMailAccount: MailAccount? @State private var testMailAccount: MailAccount?
@State private var askingTestMail = false @State private var askingTestMail = false
@State private var accountsNeedingRefresh: Set<UUID> = []
@AppStorage("offerTestMail") private var offerTestMail = true @AppStorage("offerTestMail") private var offerTestMail = true
var body: some View { var body: some View {
@@ -116,6 +117,7 @@ struct AccountsView: View {
selection = store.accounts.first?.id selection = store.accounts.first?.id
} }
.onChange(of: selection) { _, _ in error = nil; resetResult = nil } .onChange(of: selection) { _, _ in error = nil; resetResult = nil }
.onDisappear { refreshChangedAccounts() }
.confirmationDialog("Alle lokalen Mails dieses Kontos löschen?", isPresented: $confirmingReset, titleVisibility: .visible) { .confirmationDialog("Alle lokalen Mails dieses Kontos löschen?", isPresented: $confirmingReset, titleVisibility: .visible) {
Button("Lokale Mails endgültig löschen", role: .destructive) { Button("Lokale Mails endgültig löschen", role: .destructive) {
guard let account = resetAccount else { return } guard let account = resetAccount else { return }
@@ -153,11 +155,25 @@ struct AccountsView: View {
await store.refresh(folderID: nil, accountID: id) await store.refresh(folderID: nil, accountID: id)
if offerTestMail { testMailAccount = account; askingTestMail = true } if offerTestMail { testMailAccount = account; askingTestMail = true }
} }
} else {
accountsNeedingRefresh.insert(id)
} }
error = nil error = nil
} catch { self.error = error.localizedDescription } } 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() { private func testConnection() {
guard let id = selection, let account = drafts[id] else { return } guard let id = selection, let account = drafts[id] else { return }
let password = passwords[id] ?? "" let password = passwords[id] ?? ""
@@ -190,6 +206,7 @@ struct AccountsView: View {
struct AccountSettingsForm: View { struct AccountSettingsForm: View {
@Binding var account: MailAccount @Binding var account: MailAccount
@Binding var password: String @Binding var password: String
@State private var showsPassword = false
var body: some View { var body: some View {
Form { Form {
@@ -199,7 +216,21 @@ struct AccountSettingsForm: View {
TextField("Server", text: $account.host) TextField("Server", text: $account.host)
TextField("IMAP-Port (TLS)", value: $account.imapPort, format: .number.grouping(.never)) 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)) 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
View File
@@ -1,7 +1,30 @@
import SwiftUI import SwiftUI
import Observation
import AppKit import AppKit
import UniformTypeIdentifiers 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 { struct ContentView: View {
let store: MailStore let store: MailStore
@AppStorage("selectedFolder") private var savedFolder = "" @AppStorage("selectedFolder") private var savedFolder = ""
@@ -16,7 +39,7 @@ struct ContentView: View {
@AppStorage("searchRegex") private var searchRegex = false @AppStorage("searchRegex") private var searchRegex = false
@State private var selectedFolder: String? @State private var selectedFolder: String?
@State private var selectedMessages: Set<String> = [] @State private var selectedMessages: Set<String> = []
@State private var showingAccounts = false @State private var accountPresentation = AccountPresentation()
@Environment(\.openWindow) private var openWindow @Environment(\.openWindow) private var openWindow
@AppStorage("idleEnabled") private var idleEnabled = true @AppStorage("idleEnabled") private var idleEnabled = true
@AppStorage("pollMinutes") private var pollMinutes = 5 @AppStorage("pollMinutes") private var pollMinutes = 5
@@ -119,11 +142,14 @@ struct ContentView: View {
.keyboardShortcut("n", modifiers: .command).disabled(store.accounts.isEmpty) .keyboardShortcut("n", modifiers: .command).disabled(store.accounts.isEmpty)
Button("Löschen", systemImage: "trash") { deleteSelection() } Button("Löschen", systemImage: "trash") { deleteSelection() }
.disabled(selectedMessages.isEmpty) .disabled(selectedMessages.isEmpty)
Button("Konten", systemImage: "person.crop.circle") { showingAccounts = true }
SettingsLink { Label("Einstellungen", systemImage: "gearshape") } 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 .sheet(item: Binding(get: { store.authenticationAccount }, set: { value in
if value == nil { store.dismissPasswordRequest() } if value == nil { store.dismissPasswordRequest() }
})) { account in })) { account in
@@ -307,24 +333,37 @@ struct MessageTable: View {
@AppStorage("mailSortColumn") private var savedColumn = "date" @AppStorage("mailSortColumn") private var savedColumn = "date"
@AppStorage("mailSortAscending") private var savedAscending = false @AppStorage("mailSortAscending") private var savedAscending = false
@State private var sortOrder = [KeyPathComparator(\MailMessage.date, order: .reverse)] @State private var sortOrder = [KeyPathComparator(\MailMessage.date, order: .reverse)]
@SceneStorage("MessageTableColumns")
private var columnCustomization: TableColumnCustomization<MailMessage>
var body: some View { var body: some View {
Table(of: MailMessage.self, selection: $selection, sortOrder: $sortOrder) { Table(of: MailMessage.self, selection: $selection, sortOrder: $sortOrder,
TableColumn("Anhang", value: \.attachmentCount) { message in columnCustomization: $columnCustomization) {
if !message.attachments.isEmpty {
Image(systemName: "paperclip").accessibilityLabel(message.attachments.count == 1 ? "Ein Anhang" : "\(message.attachments.count) Anhänge")
}
}.width(55)
TableColumn("Absender", value: \.displaySender) { message in TableColumn("Absender", value: \.displaySender) { message in
Text(message.displaySender).fontWeight(message.isRead ? .regular : .bold) 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 TableColumn("Datum", value: \.date) { message in
if message.date == .distantPast { Text("—") } if message.date == .distantPast { Text("—") }
else { Text(MailDisplay.date(message.date)).monospacedDigit().fontWeight(message.isRead ? .regular : .bold) } 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 TableColumn("Betreff", value: \.subject) { message in
Text(message.subject).fontWeight(message.isRead ? .regular : .bold) Text(message.subject).fontWeight(message.isRead ? .regular : .bold)
}.width(min: 160, ideal: 280) }
.width(min: 160, ideal: 280)
.customizationID("subject")
} rows: { } rows: {
ForEach(messages.sorted(using: sortOrder)) { message in ForEach(messages.sorted(using: sortOrder)) { message in
TableRow(message).draggable(MailDrag.encode(selection.contains(message.id) ? selection : [message.id])) TableRow(message).draggable(MailDrag.encode(selection.contains(message.id) ? selection : [message.id]))
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -108,7 +108,7 @@ enum PasswordStore {
let status = SecItemCopyMatching(entry as CFDictionary, &result) let status = SecItemCopyMatching(entry as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data, guard status == errSecSuccess, let data = result as? Data,
let value = String(data: data, encoding: .utf8) else { 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 return value
} }
+230 -31
View File
@@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import AppKit import AppKit
import UniformTypeIdentifiers
enum MailDeleteConfirmation: String { enum MailDeleteConfirmation: String {
case always, multiple, never case always, multiple, never
@@ -25,6 +26,11 @@ struct AutoReadRequest: Equatable {
} }
struct MailSettings: View { 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("markReadImmediately") private var markReadImmediately = false
@AppStorage("markReadDelaySeconds") private var markReadDelaySeconds = 1 @AppStorage("markReadDelaySeconds") private var markReadDelaySeconds = 1
@AppStorage("bodyFontName") private var bodyFontName = "Helvetica" @AppStorage("bodyFontName") private var bodyFontName = "Helvetica"
@@ -39,44 +45,237 @@ struct MailSettings: View {
@AppStorage("showUnifiedInbox") private var showInbox = true @AppStorage("showUnifiedInbox") private var showInbox = true
@AppStorage("showUnifiedTrash") private var showTrash = 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 { var body: some View {
Form { Form {
Picker("Schriftart für Mailtexte", selection: $bodyFontName) { Section("Maildarstellung") {
ForEach(fontNames, id: \.self) { Text($0).tag($0) } 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) Section("Seitenleiste") {
Text("Vorschau Mailtext").font(.custom(bodyFontName, size: bodyFontSize)) Toggle("Gemeinsamen Posteingang anzeigen", isOn: $showInbox)
Toggle("Externe Inhalte in HTML-Mails blockieren", isOn: $blockRemoteContent) Toggle("Gemeinsamen Papierkorb anzeigen", isOn: $showTrash)
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) }
} }
Toggle("Nach neuem Konto Testmail anbieten", isOn: $offerTestMail) }
Toggle("Beim Start automatisch auf neue Mails prüfen", isOn: $checkOnLaunch) .formStyle(.grouped)
Toggle("Gemeinsamen Posteingang anzeigen", isOn: $showInbox) .padding(24)
Toggle("Gemeinsamen Papierkorb anzeigen", isOn: $showTrash) }
Picker("Als gelesen markieren", selection: $markReadImmediately) { }
Text("Sofort bei Auswahl").tag(true)
Text("Nach einer Wartezeit").tag(false) private struct RetrievalSettingsTab: View {
} @Binding var idleEnabled: Bool
if !markReadImmediately { @Binding var pollMinutes: Int
Stepper("Nach \(markReadDelaySeconds) Sekunden", value: $markReadDelaySeconds, in: 1...3600) @Binding var offerTestMail: Bool
TextField("Wartezeit in Sekunden", value: $markReadDelaySeconds, format: .number) @Binding var checkOnLaunch: Bool
.onChange(of: markReadDelaySeconds) { _, value in
markReadDelaySeconds = min(3600, max(1, value)) 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) { Section("Neue Konten") {
Text("Immer").tag(MailDeleteConfirmation.always) Toggle("Nach neuem Konto Testmail anbieten", isOn: $offerTestMail)
Text("Nur bei mehreren Nachrichten").tag(MailDeleteConfirmation.multiple)
Text("Nie").tag(MailDeleteConfirmation.never)
} }
}.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)
} }
} }
+5 -1
View File
@@ -313,7 +313,11 @@ final class MailStore {
do { do {
try await client.login(account: account, password: PasswordStore.password(for: id)) try await client.login(account: account, password: PasswordStore.password(for: id))
return client 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. /// Message mutations commit their confirmed changes locally; folder operations rediscover the tree.
+42
View File
@@ -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 -1
View File
@@ -3,6 +3,7 @@ import SwiftUI
@main @main
struct MikeMailApp: App { struct MikeMailApp: App {
@State private var store: MailStore = { @State private var store: MailStore = {
SettingsBackupManager.applyPendingRestoreIfNeeded()
#if DEBUG #if DEBUG
if ProcessInfo.processInfo.arguments.contains("--ui-fixture") { return MailStore.uiFixture() } if ProcessInfo.processInfo.arguments.contains("--ui-fixture") { return MailStore.uiFixture() }
#endif #endif
@@ -14,6 +15,7 @@ struct MikeMailApp: App {
ContentView(store: store) ContentView(store: store)
} }
.defaultSize(width: 1200, height: 800) .defaultSize(width: 1200, height: 800)
.commands { AccountCommands() }
WindowGroup("Nachricht", id: "message", for: String.self) { $id in WindowGroup("Nachricht", id: "message", for: String.self) { $id in
MessageDetailWindow(store: store, messageID: id) MessageDetailWindow(store: store, messageID: id)
}.defaultSize(width: 800, height: 650) }.defaultSize(width: 800, height: 650)
@@ -24,6 +26,6 @@ struct MikeMailApp: App {
} }
.defaultSize(width: 760, height: 780) .defaultSize(width: 760, height: 780)
.windowResizability(.contentMinSize) .windowResizability(.contentMinSize)
Settings { MailSettings() } Settings { MailSettings(store: store) }
} }
} }
+18 -3
View File
@@ -5,6 +5,7 @@ struct PasswordRecoveryView: View {
let account: MailAccount let account: MailAccount
@State private var password = "" @State private var password = ""
@State private var checking = false @State private var checking = false
@State private var showsPassword = false
@State private var error: String? @State private var error: String?
@FocusState private var passwordFocused: Bool @FocusState private var passwordFocused: Bool
@@ -12,9 +13,23 @@ struct PasswordRecoveryView: View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Text("Passwort erforderlich").font(.title2).bold() Text("Passwort erforderlich").font(.title2).bold()
Text("Der Mailserver hat die Anmeldung für \(account.email) abgelehnt.") Text("Der Mailserver hat die Anmeldung für \(account.email) abgelehnt.")
SecureField("Neues Passwort", text: $password) HStack(spacing: 8) {
.textContentType(.password) if showsPassword {
.focused($passwordFocused) 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.") Text("Das Passwort wird erst nach erfolgreicher Anmeldung im Schlüsselbund gespeichert.")
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
if let error { Text(error).foregroundStyle(.red) } if let error { Text(error).foregroundStyle(.red) }
+125
View File
@@ -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
)
}
}
}