Alpha 4 Update
This commit is contained in:
@@ -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)",
|
||||||
|
|||||||
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 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))
|
||||||
|
LabeledContent("Passwort") {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if showsPassword {
|
||||||
|
TextField("Passwort", text: $password)
|
||||||
|
} else {
|
||||||
SecureField("Passwort", text: $password)
|
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 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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+213
-14
@@ -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 {
|
||||||
|
Section("Maildarstellung") {
|
||||||
Picker("Schriftart für Mailtexte", selection: $bodyFontName) {
|
Picker("Schriftart für Mailtexte", selection: $bodyFontName) {
|
||||||
ForEach(fontNames, id: \.self) { Text($0).tag($0) }
|
ForEach(fontNames, id: \.self) { Text($0).tag($0) }
|
||||||
}
|
}
|
||||||
Stepper("Schriftgröße: \(Int(bodyFontSize)) pt", value: $bodyFontSize, in: 8...48)
|
Stepper("Schriftgröße: \(Int(bodyFontSize)) pt", value: $bodyFontSize, in: 8...48)
|
||||||
Text("Vorschau Mailtext").font(.custom(bodyFontName, size: bodyFontSize))
|
Text("Vorschau Mailtext")
|
||||||
|
.font(.custom(bodyFontName, size: bodyFontSize))
|
||||||
|
.padding(.top, 8)
|
||||||
Toggle("Externe Inhalte in HTML-Mails blockieren", isOn: $blockRemoteContent)
|
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.")
|
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)
|
.font(.caption)
|
||||||
Divider()
|
.foregroundStyle(.secondary)
|
||||||
Toggle("Sofortempfang mit IMAP IDLE", isOn: $idleEnabled)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
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)
|
Section("Seitenleiste") {
|
||||||
Toggle("Beim Start automatisch auf neue Mails prüfen", isOn: $checkOnLaunch)
|
|
||||||
Toggle("Gemeinsamen Posteingang anzeigen", isOn: $showInbox)
|
Toggle("Gemeinsamen Posteingang anzeigen", isOn: $showInbox)
|
||||||
Toggle("Gemeinsamen Papierkorb anzeigen", isOn: $showTrash)
|
Toggle("Gemeinsamen Papierkorb anzeigen", isOn: $showTrash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
Section("Neue Konten") {
|
||||||
|
Toggle("Nach neuem Konto Testmail anbieten", isOn: $offerTestMail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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) {
|
Picker("Als gelesen markieren", selection: $markReadImmediately) {
|
||||||
Text("Sofort bei Auswahl").tag(true)
|
Text("Sofort bei Auswahl").tag(true)
|
||||||
Text("Nach einer Wartezeit").tag(false)
|
Text("Nach einer Wartezeit").tag(false)
|
||||||
}
|
}
|
||||||
if !markReadImmediately {
|
if !markReadImmediately {
|
||||||
Stepper("Nach \(markReadDelaySeconds) Sekunden", value: $markReadDelaySeconds, in: 1...3600)
|
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Section("Löschen") {
|
||||||
Picker("Beim Löschen von Mails nachfragen", selection: $deleteConfirmation) {
|
Picker("Beim Löschen von Mails nachfragen", selection: $deleteConfirmation) {
|
||||||
Text("Immer").tag(MailDeleteConfirmation.always)
|
Text("Immer").tag(MailDeleteConfirmation.always)
|
||||||
Text("Nur bei mehreren Nachrichten").tag(MailDeleteConfirmation.multiple)
|
Text("Nur bei mehreren Nachrichten").tag(MailDeleteConfirmation.multiple)
|
||||||
Text("Nie").tag(MailDeleteConfirmation.never)
|
Text("Nie").tag(MailDeleteConfirmation.never)
|
||||||
}
|
}
|
||||||
}.padding(24).frame(width: 460)
|
}
|
||||||
|
}
|
||||||
|
.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 {
|
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.
|
||||||
|
|||||||
@@ -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
|
@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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if showsPassword {
|
||||||
|
TextField("Neues Passwort", text: $password)
|
||||||
|
.textContentType(.password)
|
||||||
|
.focused($passwordFocused)
|
||||||
|
} else {
|
||||||
SecureField("Neues Passwort", text: $password)
|
SecureField("Neues Passwort", text: $password)
|
||||||
.textContentType(.password)
|
.textContentType(.password)
|
||||||
.focused($passwordFocused)
|
.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) }
|
||||||
|
|||||||
@@ -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