Alpha 5
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import AppKit
|
||||
import UserNotifications
|
||||
|
||||
final class MailNotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
static let shared = MailNotificationDelegate()
|
||||
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||
) {
|
||||
completionHandler([.banner, .list, .sound])
|
||||
}
|
||||
}
|
||||
|
||||
struct NewMailNotification: Sendable {
|
||||
let sender: String
|
||||
let subject: String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum MailNotifications {
|
||||
static let enabledKey = "newMailNotifications"
|
||||
|
||||
static func configure() {
|
||||
UNUserNotificationCenter.current().delegate = MailNotificationDelegate.shared
|
||||
}
|
||||
|
||||
static func requestAuthorization() async -> Bool {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
let settings = await center.notificationSettings()
|
||||
switch settings.authorizationStatus {
|
||||
case .authorized, .provisional:
|
||||
return true
|
||||
case .notDetermined:
|
||||
return (try? await center.requestAuthorization(options: [.alert, .sound])) == true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func postNewMail(_ messages: [NewMailNotification], badgeCount: Int) async {
|
||||
guard !messages.isEmpty, UserDefaults.standard.bool(forKey: enabledKey) else { return }
|
||||
guard await requestAuthorization() else { return }
|
||||
|
||||
for message in messages {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = String(localized: "Neue Mail")
|
||||
content.subtitle = message.sender.isEmpty
|
||||
? String(localized: "Unbekannter Absender")
|
||||
: message.sender
|
||||
content.body = message.subject.isEmpty
|
||||
? String(localized: "(Ohne Betreff)")
|
||||
: message.subject
|
||||
content.sound = .default
|
||||
content.badge = NSNumber(value: badgeCount)
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: UUID().uuidString,
|
||||
content: content,
|
||||
trigger: nil
|
||||
)
|
||||
try? await UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
}
|
||||
|
||||
static func updateBadge(_ count: Int) {
|
||||
NSApp.dockTile.badgeLabel = count > 0 ? String(count) : nil
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
{ "kind": "folder", "path": "MikeMail", "target-membership": [ "MikeMail" ] },
|
||||
{ "kind": "folder", "path": "MikeMailTests", "target-membership": [ "MikeMailTests" ] },
|
||||
{ "kind": "folder", "path": "MikeMailUITests", "target-membership": [ "MikeMailUITests" ] },
|
||||
{ "path": "MailNotifications.swift", "target-membership": [ "MikeMail/compile-sources" ] },
|
||||
{
|
||||
"kind": "group",
|
||||
"name": "Products",
|
||||
@@ -45,8 +46,17 @@
|
||||
"DEVELOPMENT_TEAM": "GQR972L9NJ",
|
||||
"ENABLE_APP_SANDBOX": "YES",
|
||||
"ENABLE_HARDENED_RUNTIME": "YES",
|
||||
"ENABLE_INCOMING_NETWORK_CONNECTIONS": "NO",
|
||||
"ENABLE_OUTGOING_NETWORK_CONNECTIONS": "YES",
|
||||
"ENABLE_PREVIEWS": "YES",
|
||||
"ENABLE_RESOURCE_ACCESS_AUDIO_INPUT": "NO",
|
||||
"ENABLE_RESOURCE_ACCESS_BLUETOOTH": "NO",
|
||||
"ENABLE_RESOURCE_ACCESS_CALENDARS": "NO",
|
||||
"ENABLE_RESOURCE_ACCESS_CAMERA": "NO",
|
||||
"ENABLE_RESOURCE_ACCESS_CONTACTS": "NO",
|
||||
"ENABLE_RESOURCE_ACCESS_LOCATION": "NO",
|
||||
"ENABLE_RESOURCE_ACCESS_PRINTING": "YES",
|
||||
"ENABLE_RESOURCE_ACCESS_USB": "NO",
|
||||
"ENABLE_USER_SELECTED_FILES": "readwrite",
|
||||
"GENERATE_INFOPLIST_FILE": "YES",
|
||||
"INFOPLIST_KEY_NSHumanReadableCopyright": "",
|
||||
|
||||
MikeMail.xcodeproj/project.xcworkspace/xcuserdata/michael.xcuserdatad/UserInterfaceState.xcuserstate
Generated
BIN
Binary file not shown.
@@ -157,7 +157,10 @@ struct MailSidebarList: NSViewRepresentable {
|
||||
stack.addArrangedSubview(spacer)
|
||||
}
|
||||
let icon = NSImageView(image: NSImage(systemSymbolName: entry.icon, accessibilityDescription: nil) ?? NSImage())
|
||||
icon.contentTintColor = .controlAccentColor
|
||||
let isTrash = entry.id == "unified:trash" || entry.folder?.isTrash == true
|
||||
icon.contentTintColor = isTrash
|
||||
? (NSColor.systemRed.blended(withFraction: 0.35, of: .black) ?? .systemRed)
|
||||
: .controlAccentColor
|
||||
icon.widthAnchor.constraint(equalToConstant: 18).isActive = true
|
||||
stack.addArrangedSubview(icon)
|
||||
let texts = NSStackView()
|
||||
@@ -178,12 +181,7 @@ struct MailSidebarList: NSViewRepresentable {
|
||||
}
|
||||
texts.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
stack.addArrangedSubview(texts)
|
||||
if entry.account != nil {
|
||||
let handle = NSImageView(image: NSImage(systemSymbolName: "line.3.horizontal", accessibilityDescription: "Konto verschieben") ?? NSImage())
|
||||
handle.widthAnchor.constraint(equalToConstant: 18).isActive = true
|
||||
handle.toolTip = "Kontenzeile an die gewünschte Position ziehen"
|
||||
stack.addArrangedSubview(handle)
|
||||
} else {
|
||||
if entry.account == nil {
|
||||
let count = parent.store.unreadCount(folderID: entry.id)
|
||||
if count > 0 { stack.addArrangedSubview(NSTextField(labelWithString: String(count))) }
|
||||
}
|
||||
|
||||
@@ -211,6 +211,10 @@ struct AccountSettingsForm: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
TextField("Name", text: $account.name)
|
||||
TextField("Anzeigename", text: Binding(
|
||||
get: { account.displayName ?? "" },
|
||||
set: { account.displayName = $0 }
|
||||
))
|
||||
TextField("E-Mail-Adresse", text: $account.email).accessibilityIdentifier("accountEmail")
|
||||
TextField("Benutzername", text: $account.username)
|
||||
TextField("Server", text: $account.host)
|
||||
|
||||
+194
-33
@@ -41,8 +41,6 @@ struct ContentView: View {
|
||||
@State private var selectedMessages: Set<String> = []
|
||||
@State private var accountPresentation = AccountPresentation()
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@AppStorage("idleEnabled") private var idleEnabled = true
|
||||
@AppStorage("pollMinutes") private var pollMinutes = 5
|
||||
@AppStorage("mailDeleteConfirmation") private var deleteConfirmation: MailDeleteConfirmation = .always
|
||||
@AppStorage("markReadImmediately") private var markReadImmediately = false
|
||||
@AppStorage("markReadDelaySeconds") private var markReadDelaySeconds = 1
|
||||
@@ -107,12 +105,10 @@ struct ContentView: View {
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
HStack {
|
||||
if store.isBusy { ProgressView().controlSize(.small) }
|
||||
if idleEnabled {
|
||||
Image(systemName: "bolt.horizontal.circle")
|
||||
.foregroundStyle(store.idleStatuses.values.contains("Sofortempfang aktiv") ? .green : .secondary)
|
||||
.help(store.accounts.map { "\($0.email): \(store.idleStatuses[$0.id] ?? "Verbindung wird vorbereitet")" }.joined(separator: "\n"))
|
||||
.accessibilityLabel("Sofortempfang")
|
||||
}
|
||||
Image(systemName: "bolt.horizontal.circle")
|
||||
.foregroundStyle(store.idleStatuses.values.contains("Sofortempfang aktiv") ? .green : .secondary)
|
||||
.help(store.accounts.map { "\($0.email): \(store.idleStatuses[$0.id] ?? "Verbindung wird vorbereitet")" }.joined(separator: "\n"))
|
||||
.accessibilityLabel("Sofortempfang")
|
||||
Text(store.status).font(.caption).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text("\(store.messages.count) Nachrichten").font(.caption).foregroundStyle(.secondary)
|
||||
@@ -142,7 +138,6 @@ struct ContentView: View {
|
||||
.keyboardShortcut("n", modifiers: .command).disabled(store.accounts.isEmpty)
|
||||
Button("Löschen", systemImage: "trash") { deleteSelection() }
|
||||
.disabled(selectedMessages.isEmpty)
|
||||
SettingsLink { Label("Einstellungen", systemImage: "gearshape") }
|
||||
}
|
||||
}
|
||||
.focusedSceneValue(\.accountPresentation, accountPresentation)
|
||||
@@ -155,10 +150,12 @@ struct ContentView: View {
|
||||
})) { account in
|
||||
PasswordRecoveryView(store: store, account: account)
|
||||
}
|
||||
.task(id: IdleConfiguration(enabled: idleEnabled, accounts: store.accounts)) {
|
||||
store.configureIdle(enabled: idleEnabled)
|
||||
.task(id: IdleConfiguration(accounts: store.accounts)) {
|
||||
store.configureIdle()
|
||||
}
|
||||
.task(id: store.totalUnreadCount) {
|
||||
MailNotifications.updateBadge(store.totalUnreadCount)
|
||||
}
|
||||
.task(id: pollMinutes) { await store.poll(intervalMinutes: pollMinutes) }
|
||||
.task(id: AutoReadRequest(ids: selectedMessages, immediately: markReadImmediately, seconds: markReadDelaySeconds)) {
|
||||
guard selectedMessages.count == 1, let id = selectedMessages.first,
|
||||
let message = store.message(id: id), !message.isRead else { return }
|
||||
@@ -212,7 +209,13 @@ struct ContentView: View {
|
||||
guard !ids.isEmpty else { return }
|
||||
switch command {
|
||||
case .open:
|
||||
if ids.count == 1, let id = ids.first { openWindow(id: "message", value: id) }
|
||||
if ids.count == 1, let id = ids.first, let message = store.message(id: id) {
|
||||
if selectedFolder.flatMap({ store.folder(id: $0) })?.isDrafts == true {
|
||||
openComposer(.saved(message, accountID: store.accountID(for: id)))
|
||||
} else {
|
||||
openWindow(id: "message", value: id)
|
||||
}
|
||||
}
|
||||
case .reply, .forward:
|
||||
if ids.count == 1, let id = ids.first, let message = store.message(id: id) {
|
||||
openComposer(.from(message, accountID: store.accountID(for: id), forwarding: command == .forward))
|
||||
@@ -465,6 +468,33 @@ struct MessageReader: View {
|
||||
}
|
||||
}
|
||||
|
||||
enum AttachmentMentionDetector {
|
||||
static let settingKey = "warnMissingAttachment"
|
||||
|
||||
private static let phrases = [
|
||||
"im anhang",
|
||||
"in der anlage",
|
||||
"als anhang",
|
||||
"siehe anhang",
|
||||
"siehe anlage",
|
||||
"anbei",
|
||||
"beigefügt",
|
||||
"beigefuegt",
|
||||
"angehängt",
|
||||
"angehaengt",
|
||||
"attachment",
|
||||
"attached",
|
||||
"enclosed"
|
||||
]
|
||||
|
||||
static func mentionsAttachment(in text: String) -> Bool {
|
||||
let normalized = text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
|
||||
return phrases.contains { phrase in
|
||||
normalized.contains(phrase.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ComposeView: View {
|
||||
let store: MailStore
|
||||
var draft: ComposeDraft? = nil
|
||||
@@ -477,6 +507,12 @@ struct ComposeView: View {
|
||||
@State private var subject = ""
|
||||
@State private var editor = MailEditorModel()
|
||||
@State private var htmlEnabled = true
|
||||
@State private var confirmingPlainText = false
|
||||
@State private var confirmingMissingAttachment = false
|
||||
@State private var confirmingClose = false
|
||||
@State private var closeApproved = false
|
||||
@State private var showAdditionalRecipients = false
|
||||
@AppStorage(AttachmentMentionDetector.settingKey) private var warnMissingAttachment = true
|
||||
@AppStorage("bodyFontName") private var bodyFontName = "Helvetica"
|
||||
@AppStorage("bodyFontSize") private var bodyFontSize = 14.0
|
||||
@State private var attachments: [MailAttachment] = []
|
||||
@@ -491,21 +527,49 @@ struct ComposeView: View {
|
||||
Text("Neue Nachricht").font(.title2).bold()
|
||||
Form {
|
||||
Picker("Von", selection: $accountID) {
|
||||
ForEach(store.accounts) { account in Text(account.email).tag(Optional(account.id)) }
|
||||
ForEach(store.accounts) { account in
|
||||
Text(account.senderDisplay).tag(Optional(account.id))
|
||||
}
|
||||
}
|
||||
RecipientField(recipient: $recipient, history: store.recipientHistory)
|
||||
TextField("CC", text: $cc).accessibilityIdentifier("composeCC")
|
||||
TextField("BCC", text: $bcc).accessibilityIdentifier("composeBCC")
|
||||
Toggle("CC/BCC aktivieren", isOn: $showAdditionalRecipients)
|
||||
.toggleStyle(.checkbox)
|
||||
if showAdditionalRecipients {
|
||||
TextField("CC (mehrere mit Semikolon trennen)", text: $cc)
|
||||
.accessibilityIdentifier("composeCC")
|
||||
TextField("BCC (mehrere mit Semikolon trennen)", text: $bcc)
|
||||
.accessibilityIdentifier("composeBCC")
|
||||
}
|
||||
TextField("Betreff", text: $subject)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
Toggle("HTML-Nachricht", isOn: $htmlEnabled)
|
||||
Toggle("HTML-Nachricht", isOn: Binding(
|
||||
get: { htmlEnabled },
|
||||
set: { enabled in
|
||||
if enabled {
|
||||
htmlEnabled = true
|
||||
} else if editor.hasRichTextFormatting {
|
||||
confirmingPlainText = true
|
||||
} else {
|
||||
htmlEnabled = false
|
||||
}
|
||||
}
|
||||
))
|
||||
if htmlEnabled { MailFormattingBar(editor: editor) }
|
||||
MailBodyEditor(model: editor, initialText: draft?.body ?? "",
|
||||
MailBodyEditor(model: editor, initialText: draft?.body ?? "", initialHTML: draft?.htmlBody,
|
||||
fontName: bodyFontName, fontSize: bodyFontSize, focusOnOpen: draft?.isReply == true,
|
||||
richText: htmlEnabled)
|
||||
.frame(minHeight: 180, maxHeight: .infinity).border(.quaternary)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label(attachments.count == 1 ? "Anhang – Dateien hier ablegen" : "Anhänge – Dateien hier ablegen", systemImage: "paperclip").font(.headline)
|
||||
Label(attachments.count == 1 ? "Anhang – Dateien hier ablegen" : "Anhänge – Dateien hier ablegen", systemImage: "paperclip")
|
||||
.font(.body)
|
||||
.fontWeight(.regular)
|
||||
.foregroundStyle(Color.blue.opacity(0.8))
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
minHeight: attachments.isEmpty ? 68 : nil,
|
||||
alignment: .center
|
||||
)
|
||||
ScrollView {
|
||||
ForEach(attachments) { attachment in
|
||||
HStack {
|
||||
@@ -516,12 +580,18 @@ struct ComposeView: View {
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
}
|
||||
}.frame(height: attachments.isEmpty ? 16 : 80)
|
||||
}.frame(height: attachments.isEmpty ? 0 : 80)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.accentColor.opacity(dropTargeted ? 0.18 : 0.06), in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(dropTargeted ? Color.accentColor : Color.secondary.opacity(0.3)))
|
||||
.background(Color.blue.opacity(dropTargeted ? 0.12 : 0.04), in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(
|
||||
dropTargeted ? Color.blue.opacity(0.9) : Color.blue.opacity(0.35),
|
||||
style: StrokeStyle(lineWidth: 1.5, dash: [7, 5])
|
||||
)
|
||||
}
|
||||
.dropDestination(for: URL.self) { urls, _ in
|
||||
guard !sending, urls.allSatisfy(\.isFileURL) else { return false }
|
||||
addAttachments(urls)
|
||||
@@ -529,21 +599,17 @@ struct ComposeView: View {
|
||||
} isTargeted: { dropTargeted = $0 }
|
||||
if let error { Text(error).foregroundStyle(.red).textSelection(.enabled) }
|
||||
HStack {
|
||||
Button("Abbrechen") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||
Button("Abbrechen") { requestClose() }.keyboardShortcut(.cancelAction)
|
||||
Button("Anhang hinzufügen", systemImage: "paperclip") { importing = true }
|
||||
Spacer()
|
||||
if sending { ProgressView().controlSize(.small) }
|
||||
Button("Senden", systemImage: "paperplane") {
|
||||
sending = true
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
try await store.send(accountID: accountID, recipients: recipient, cc: cc, bcc: bcc,
|
||||
subject: subject, body: editor.plainText, attachments: attachments,
|
||||
htmlBody: htmlEnabled ? try editor.html() : nil)
|
||||
dismiss()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
sending = false
|
||||
let text = subject + "\n" + editor.plainText
|
||||
if warnMissingAttachment && attachments.isEmpty
|
||||
&& AttachmentMentionDetector.mentionsAttachment(in: text) {
|
||||
confirmingMissingAttachment = true
|
||||
} else {
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
@@ -551,6 +617,13 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.background {
|
||||
WindowCloseGuard(
|
||||
shouldClose: { closeApproved || (!hasComposedContent && !sending) },
|
||||
onCloseAttempt: { if !sending { requestClose() } }
|
||||
)
|
||||
.frame(width: 0, height: 0)
|
||||
}
|
||||
.frame(minWidth: 640, idealWidth: 760, maxWidth: .infinity, minHeight: 600, idealHeight: 780, maxHeight: .infinity)
|
||||
.disabled(sending)
|
||||
.interactiveDismissDisabled(sending)
|
||||
@@ -559,13 +632,101 @@ struct ComposeView: View {
|
||||
initialized = true
|
||||
accountID = draft?.accountID ?? defaultAccountID ?? store.accounts.first?.id
|
||||
recipient = draft?.recipient ?? ""
|
||||
cc = draft?.cc ?? ""
|
||||
bcc = draft?.bcc ?? ""
|
||||
subject = draft?.subject ?? ""
|
||||
htmlEnabled = draft?.htmlBody != nil || draft == nil
|
||||
showAdditionalRecipients = !cc.isEmpty || !bcc.isEmpty
|
||||
attachments = draft?.attachments ?? []
|
||||
}
|
||||
.fileImporter(isPresented: $importing, allowedContentTypes: [.data], allowsMultipleSelection: true) { result in
|
||||
do { addAttachments(try result.get()) }
|
||||
catch { self.error = error.localizedDescription }
|
||||
}
|
||||
.alert("HTML-Formatierung entfernen?", isPresented: $confirmingPlainText) {
|
||||
Button("Abbrechen", role: .cancel) {}
|
||||
Button("Formatierungen entfernen", role: .destructive) {
|
||||
editor.removeRichTextFormatting()
|
||||
htmlEnabled = false
|
||||
}
|
||||
} message: {
|
||||
Text("Beim Deaktivieren der HTML-Nachricht gehen alle Textformatierungen verloren.")
|
||||
}
|
||||
.alert("Nachricht schließen?", isPresented: $confirmingClose) {
|
||||
Button("Zurück zur Mail", role: .cancel) {}
|
||||
Button("Als Entwurf speichern") { saveDraftAndClose() }
|
||||
Button("Löschen", role: .destructive) {
|
||||
closeApproved = true
|
||||
DispatchQueue.main.async { dismiss() }
|
||||
}
|
||||
} message: {
|
||||
Text("Möchtest du die verfasste Nachricht löschen oder als Entwurf speichern?")
|
||||
}
|
||||
.alert("Anhang vergessen?", isPresented: $confirmingMissingAttachment) {
|
||||
Button("Abbrechen", role: .cancel) {}
|
||||
Button("Trotzdem senden") { sendMessage() }
|
||||
} message: {
|
||||
Text("Der Nachrichtentext deutet auf einen Anhang hin, es wurde aber keine Datei angefügt.")
|
||||
}
|
||||
}
|
||||
|
||||
private var hasComposedContent: Bool {
|
||||
!recipient.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| !cc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| !bcc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| !subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| !editor.plainText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| !attachments.isEmpty
|
||||
}
|
||||
|
||||
private func requestClose() {
|
||||
if hasComposedContent {
|
||||
confirmingClose = true
|
||||
} else {
|
||||
closeApproved = true
|
||||
DispatchQueue.main.async { dismiss() }
|
||||
}
|
||||
}
|
||||
|
||||
private func saveDraftAndClose() {
|
||||
sending = true
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
try await store.saveDraft(
|
||||
accountID: accountID,
|
||||
recipients: recipient,
|
||||
cc: cc,
|
||||
bcc: bcc,
|
||||
subject: subject,
|
||||
body: editor.plainText,
|
||||
attachments: attachments,
|
||||
htmlBody: htmlEnabled ? try editor.html() : nil
|
||||
)
|
||||
closeApproved = true
|
||||
DispatchQueue.main.async { dismiss() }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
sending = false
|
||||
}
|
||||
}
|
||||
|
||||
private func sendMessage() {
|
||||
sending = true
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
try await store.send(accountID: accountID, recipients: recipient, cc: cc, bcc: bcc,
|
||||
subject: subject, body: editor.plainText, attachments: attachments,
|
||||
htmlBody: htmlEnabled ? try editor.html() : nil)
|
||||
closeApproved = true
|
||||
DispatchQueue.main.async { dismiss() }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
sending = false
|
||||
}
|
||||
}
|
||||
|
||||
private func addAttachments(_ urls: [URL]) {
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"(Ohne Betreff)" : {
|
||||
|
||||
},
|
||||
"%@" : {
|
||||
"localizations" : {
|
||||
@@ -224,6 +227,7 @@
|
||||
}
|
||||
},
|
||||
"Alle %lld Minuten" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"variations" : {
|
||||
@@ -314,6 +318,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Als Entwurf speichern" : {
|
||||
|
||||
},
|
||||
"Als gelesen markieren" : {
|
||||
"localizations" : {
|
||||
@@ -336,6 +343,7 @@
|
||||
}
|
||||
},
|
||||
"An (mehrere mit Komma trennen)" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -344,6 +352,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"An (mehrere mit Semikolon trennen)" : {
|
||||
|
||||
},
|
||||
"An: %@" : {
|
||||
"localizations" : {
|
||||
@@ -384,6 +395,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Anhang vergessen?" : {
|
||||
|
||||
},
|
||||
"Anhänge – Dateien hier ablegen" : {
|
||||
"localizations" : {
|
||||
@@ -404,8 +418,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Anzeigename" : {
|
||||
|
||||
},
|
||||
"Auf neue Mails prüfen" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -426,6 +444,7 @@
|
||||
}
|
||||
},
|
||||
"BCC" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -434,6 +453,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"BCC (mehrere mit Semikolon trennen)" : {
|
||||
|
||||
},
|
||||
"Bei neuen Mails benachrichtigen" : {
|
||||
|
||||
},
|
||||
"Beim Deaktivieren der HTML-Nachricht gehen alle Textformatierungen verloren." : {
|
||||
|
||||
},
|
||||
"Beim Löschen von Mails nachfragen" : {
|
||||
"localizations" : {
|
||||
@@ -479,6 +507,7 @@
|
||||
}
|
||||
},
|
||||
"CC" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -487,6 +516,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"CC (mehrere mit Semikolon trennen)" : {
|
||||
|
||||
},
|
||||
"CC: %@" : {
|
||||
"localizations" : {
|
||||
@@ -497,6 +529,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"CC/BCC aktivieren" : {
|
||||
|
||||
},
|
||||
"Darstellung" : {
|
||||
|
||||
@@ -556,6 +591,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Der Nachrichtentext deutet auf einen Anhang hin, es wurde aber keine Datei angefügt." : {
|
||||
|
||||
},
|
||||
"Der Ordner wird auf dem Mailserver erstellt." : {
|
||||
"localizations" : {
|
||||
@@ -569,6 +607,9 @@
|
||||
},
|
||||
"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." : {
|
||||
|
||||
},
|
||||
"Die Anzahl ungelesener Mails wird immer am App-Symbol angezeigt." : {
|
||||
|
||||
},
|
||||
"Die ausgewählten Nachrichten werden auch auf dem Server in den Papierkorb verschoben." : {
|
||||
"localizations" : {
|
||||
@@ -619,6 +660,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Drucken …" : {
|
||||
|
||||
},
|
||||
"Durchgestrichen" : {
|
||||
"localizations" : {
|
||||
@@ -671,6 +715,7 @@
|
||||
}
|
||||
},
|
||||
"Einstellungen" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -751,6 +796,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Formatierungen entfernen" : {
|
||||
|
||||
},
|
||||
"Füge ein Konto hinzu oder wähle links ein Konto aus." : {
|
||||
"localizations" : {
|
||||
@@ -791,6 +839,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"HTML-Formatierung entfernen?" : {
|
||||
|
||||
},
|
||||
"HTML-Nachricht" : {
|
||||
"localizations" : {
|
||||
@@ -1011,6 +1062,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Mitteilungen" : {
|
||||
|
||||
},
|
||||
"Möchtest du die verfasste Nachricht löschen oder als Entwurf speichern?" : {
|
||||
|
||||
},
|
||||
"Nach %lld Sekunden" : {
|
||||
"localizations" : {
|
||||
@@ -1113,6 +1170,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Nachricht schließen?" : {
|
||||
|
||||
},
|
||||
"Nachrichten durchsuchen" : {
|
||||
"localizations" : {
|
||||
@@ -1156,8 +1216,12 @@
|
||||
},
|
||||
"Neue Konten" : {
|
||||
|
||||
},
|
||||
"Neue Mail" : {
|
||||
|
||||
},
|
||||
"Neue Mails werden bei laufender App sofort gemeldet. Das Prüfintervall dient zusätzlich als Absicherung." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -1238,6 +1302,7 @@
|
||||
}
|
||||
},
|
||||
"Nur manuell" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -1369,6 +1434,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Prüft Betreff und Nachrichtentext vor dem Senden auf Hinweise wie „im Anhang“, „anbei“ oder „beigefügt“." : {
|
||||
|
||||
},
|
||||
"Rechtschreib- und Grammatikprüfung" : {
|
||||
|
||||
},
|
||||
"Rechtschreibprüfung" : {
|
||||
|
||||
},
|
||||
"Regulärer Ausdruck" : {
|
||||
"localizations" : {
|
||||
@@ -1487,6 +1561,7 @@
|
||||
}
|
||||
},
|
||||
"Sofortempfang mit IMAP IDLE" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -1545,6 +1620,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Trotzdem senden" : {
|
||||
|
||||
},
|
||||
"Unbekannter Absender" : {
|
||||
|
||||
},
|
||||
"Unterstrichen" : {
|
||||
"localizations" : {
|
||||
@@ -1565,6 +1646,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Verfassen" : {
|
||||
|
||||
},
|
||||
"Verhalten" : {
|
||||
|
||||
@@ -1588,6 +1672,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vor möglicherweise vergessenem Anhang warnen" : {
|
||||
|
||||
},
|
||||
"Vorschau Mailtext" : {
|
||||
"localizations" : {
|
||||
@@ -1629,6 +1716,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Zurück zur Mail" : {
|
||||
|
||||
}
|
||||
},
|
||||
"version" : "1.3"
|
||||
|
||||
+11
-5
@@ -94,8 +94,8 @@ enum MIME {
|
||||
return MailMessage(id: id, sender: decodeHeader(fields["from"] ?? ""),
|
||||
recipient: decodeHeader(fields["to"] ?? ""), subject: decodeHeader(fields["subject"] ?? "(Ohne Betreff)"),
|
||||
date: date ?? .distantPast, body: bodies.isEmpty ? plainText(htmlBodies.joined(separator: "\n")) : bodies.joined(separator: "\n"),
|
||||
attachments: attachments, cc: decodeHeader(fields["cc"] ?? ""), replyTo: decodeHeader(fields["reply-to"] ?? ""),
|
||||
htmlBody: htmlBodies.isEmpty ? nil : htmlBodies.joined(separator: "\n"))
|
||||
attachments: attachments, cc: decodeHeader(fields["cc"] ?? ""), bcc: decodeHeader(fields["bcc"] ?? ""),
|
||||
replyTo: decodeHeader(fields["reply-to"] ?? ""), htmlBody: htmlBodies.isEmpty ? nil : htmlBodies.joined(separator: "\n"))
|
||||
}
|
||||
|
||||
private static func parsePart(_ data: Data, depth: Int, bodies: inout [String], htmlBodies: inout [String], attachments: inout [MailAttachment]) {
|
||||
@@ -151,8 +151,10 @@ enum MIME {
|
||||
value.range(of: "^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$", options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
static func compose(from: String, to: [String], cc: [String] = [], bcc: [String] = [], subject: String, body: String, attachments: [MailAttachment], htmlBody: String? = nil) throws -> Data {
|
||||
guard validAddress(from), !(to + cc + bcc).isEmpty, (to + cc + bcc).allSatisfy(validAddress),
|
||||
static func compose(from: String, senderName: String? = nil, to: [String], cc: [String] = [], bcc: [String] = [], subject: String, body: String, attachments: [MailAttachment], htmlBody: String? = nil, allowEmptyRecipients: Bool = false, includeBccHeader: Bool = false) throws -> Data {
|
||||
let trimmedSenderName = senderName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard validAddress(from), (allowEmptyRecipients || !(to + cc + bcc).isEmpty), (to + cc + bcc).allSatisfy(validAddress),
|
||||
!trimmedSenderName.unicodeScalars.contains(where: { CharacterSet.newlines.contains($0) }),
|
||||
!subject.unicodeScalars.contains(where: { CharacterSet.newlines.contains($0) }) else {
|
||||
throw MailError.message("Bitte gültige E-Mail-Adressen und einen einzeiligen Betreff eingeben.")
|
||||
}
|
||||
@@ -163,8 +165,12 @@ enum MIME {
|
||||
func base64(_ data: Data) -> String {
|
||||
data.base64EncodedString(options: [.lineLength76Characters, .endLineWithCarriageReturn, .endLineWithLineFeed])
|
||||
}
|
||||
var result = "From: \(from)\r\nTo: \(to.joined(separator: ", "))\r\n"
|
||||
let fromHeader = trimmedSenderName.isEmpty
|
||||
? from
|
||||
: "=?UTF-8?B?\(Data(trimmedSenderName.utf8).base64EncodedString())?= <\(from)>"
|
||||
var result = "From: \(fromHeader)\r\nTo: \(to.joined(separator: ", "))\r\n"
|
||||
if !cc.isEmpty { result += "Cc: \(cc.joined(separator: ", "))\r\n" }
|
||||
if includeBccHeader && !bcc.isEmpty { result += "Bcc: \(bcc.joined(separator: ", "))\r\n" }
|
||||
result += "Subject: =?UTF-8?B?\(Data(subject.utf8).base64EncodedString())?=\r\n"
|
||||
result += "Date: \(dateFormatter.string(from: Date()))\r\nMessage-ID: <\(UUID().uuidString)@mikemail.local>\r\n"
|
||||
result += "MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"\(boundary)\"\r\n\r\n"
|
||||
|
||||
@@ -12,6 +12,7 @@ final class MailEditorModel {
|
||||
var italic = false
|
||||
var underline = false
|
||||
var strikethrough = false
|
||||
var spellCheckingEnabled = true
|
||||
|
||||
var plainText: String { textView?.string ?? "" }
|
||||
|
||||
@@ -87,6 +88,52 @@ final class MailEditorModel {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
func setSpellCheckingEnabled(_ enabled: Bool) {
|
||||
spellCheckingEnabled = enabled
|
||||
textView?.isContinuousSpellCheckingEnabled = enabled
|
||||
textView?.isGrammarCheckingEnabled = enabled
|
||||
}
|
||||
|
||||
var hasRichTextFormatting: Bool {
|
||||
guard let view = textView else { return false }
|
||||
let text = view.attributedString()
|
||||
guard text.length > 0 else { return false }
|
||||
let baseFont = view.font ?? NSFont.systemFont(ofSize: 14)
|
||||
var found = false
|
||||
text.enumerateAttributes(in: NSRange(location: 0, length: text.length)) { attributes, _, stop in
|
||||
let font = attributes[.font] as? NSFont ?? baseFont
|
||||
let traits = NSFontManager.shared.traits(of: font)
|
||||
let hasFontFormatting = font.familyName != baseFont.familyName
|
||||
|| font.pointSize != baseFont.pointSize
|
||||
|| traits.contains(.boldFontMask)
|
||||
|| traits.contains(.italicFontMask)
|
||||
let hasDecoration = (attributes[.underlineStyle] as? Int ?? 0) != 0
|
||||
|| (attributes[.strikethroughStyle] as? Int ?? 0) != 0
|
||||
let hasColor = (attributes[.foregroundColor] as? NSColor)?.isEqual(NSColor.textColor) == false
|
||||
|| attributes[.backgroundColor] != nil
|
||||
if hasFontFormatting || hasDecoration || hasColor || attributes[.link] != nil {
|
||||
found = true
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func removeRichTextFormatting() {
|
||||
guard let view = textView, let storage = view.textStorage else { return }
|
||||
let range = NSRange(location: 0, length: storage.length)
|
||||
storage.setAttributes([
|
||||
.font: view.font ?? NSFont.systemFont(ofSize: 14),
|
||||
.foregroundColor: NSColor.textColor
|
||||
], range: range)
|
||||
view.typingAttributes = [
|
||||
.font: view.font ?? NSFont.systemFont(ofSize: 14),
|
||||
.foregroundColor: NSColor.textColor
|
||||
]
|
||||
view.didChangeText()
|
||||
refreshSelection()
|
||||
}
|
||||
}
|
||||
|
||||
struct MailFormattingBar: View {
|
||||
@@ -118,6 +165,14 @@ struct MailFormattingBar: View {
|
||||
Button { editor.setAttribute(.strikethroughStyle, value: editor.strikethrough ? 0 : NSUnderlineStyle.single.rawValue) } label: {
|
||||
Image(systemName: "strikethrough").foregroundStyle(editor.strikethrough ? Color.accentColor : .primary)
|
||||
}.help("Durchgestrichen")
|
||||
Divider()
|
||||
.frame(height: 20)
|
||||
Toggle("Rechtschreibprüfung", isOn: Binding(
|
||||
get: { editor.spellCheckingEnabled },
|
||||
set: { editor.setSpellCheckingEnabled($0) }
|
||||
))
|
||||
.toggleStyle(.switch)
|
||||
.help("Rechtschreib- und Grammatikprüfung")
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(8).background(.quaternary, in: RoundedRectangle(cornerRadius: 6))
|
||||
@@ -127,6 +182,7 @@ struct MailFormattingBar: View {
|
||||
struct MailBodyEditor: NSViewRepresentable {
|
||||
let model: MailEditorModel
|
||||
let initialText: String
|
||||
let initialHTML: String?
|
||||
let fontName: String
|
||||
let fontSize: Double
|
||||
let focusOnOpen: Bool
|
||||
@@ -141,9 +197,23 @@ struct MailBodyEditor: NSViewRepresentable {
|
||||
view.importsGraphics = false
|
||||
view.allowsUndo = true
|
||||
view.isAutomaticQuoteSubstitutionEnabled = false
|
||||
view.isContinuousSpellCheckingEnabled = model.spellCheckingEnabled
|
||||
view.isGrammarCheckingEnabled = model.spellCheckingEnabled
|
||||
view.textContainerInset = NSSize(width: 8, height: 8)
|
||||
view.font = NSFont(name: fontName, size: fontSize) ?? .systemFont(ofSize: fontSize)
|
||||
view.string = initialText
|
||||
if let initialHTML,
|
||||
let attributedText = try? NSAttributedString(
|
||||
data: Data(initialHTML.utf8),
|
||||
options: [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
],
|
||||
documentAttributes: nil
|
||||
) {
|
||||
view.textStorage?.setAttributedString(attributedText)
|
||||
} else {
|
||||
view.string = initialText
|
||||
}
|
||||
view.setSelectedRange(NSRange(location: 0, length: 0))
|
||||
view.setAccessibilityIdentifier("composeBody")
|
||||
view.delegate = context.coordinator
|
||||
@@ -158,6 +228,8 @@ struct MailBodyEditor: NSViewRepresentable {
|
||||
func updateNSView(_ scroll: NSScrollView, context: Context) {
|
||||
guard let view = scroll.documentView as? NSTextView else { return }
|
||||
view.isRichText = richText
|
||||
view.isContinuousSpellCheckingEnabled = model.spellCheckingEnabled
|
||||
view.isGrammarCheckingEnabled = model.spellCheckingEnabled
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, NSTextViewDelegate {
|
||||
|
||||
@@ -102,6 +102,5 @@ final class MailIdleMonitor {
|
||||
}
|
||||
|
||||
struct IdleConfiguration: Equatable {
|
||||
let enabled: Bool
|
||||
let accounts: [MailAccount]
|
||||
}
|
||||
|
||||
@@ -4,12 +4,18 @@ import Security
|
||||
struct MailAccount: Identifiable, Codable, Hashable, Sendable {
|
||||
var id = UUID()
|
||||
var name = ""
|
||||
var displayName: String?
|
||||
var email = ""
|
||||
var username = ""
|
||||
var host = ""
|
||||
var smtpPort: UInt16 = 587
|
||||
var imapPort: UInt16 = 993
|
||||
|
||||
var senderDisplay: String {
|
||||
let value = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return value.isEmpty ? email : "\(value) <\(email)>"
|
||||
}
|
||||
|
||||
static let testAccount = MailAccount(name: "Testkonto", email: "test@kuebler-sulzbach.de",
|
||||
username: "test@kuebler-sulzbach.de", host: "w0099985.kasserver.com")
|
||||
}
|
||||
@@ -29,14 +35,40 @@ struct MailFolder: Identifiable, Hashable, Codable, Sendable {
|
||||
specialUse?.contains("\\trash") == true || ["trash", "papierkorb", "deleted messages", "deleted items"].contains(name.lowercased())
|
||||
}
|
||||
|
||||
var isDrafts: Bool {
|
||||
specialUse?.contains("\\drafts") == true
|
||||
|| ["drafts", "entwürfe", "entwuerfe"].contains(name.lowercased())
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch path.lowercased() {
|
||||
case "inbox": "tray"
|
||||
case let value where value.contains("sent"): "paperplane"
|
||||
case let value where value.contains("trash"): "trash"
|
||||
case let value where value.contains("draft"): "doc"
|
||||
default: "folder"
|
||||
if isInbox { return "tray" }
|
||||
if isTrash { return "trash" }
|
||||
|
||||
let uses = Set((specialUse ?? []).map { $0.lowercased() })
|
||||
let value = (path + " " + name).lowercased()
|
||||
|
||||
if uses.contains("\\sent") || value.contains("sent") || value.contains("gesendet") {
|
||||
return "paperplane"
|
||||
}
|
||||
if uses.contains("\\drafts") || value.contains("draft") || value.contains("entwurf") {
|
||||
return "doc.text"
|
||||
}
|
||||
if uses.contains("\\junk") || value.contains("junk") || value.contains("spam") {
|
||||
return "exclamationmark.octagon"
|
||||
}
|
||||
if uses.contains("\\archive") || value.contains("archive") || value.contains("archiv") {
|
||||
return "archivebox"
|
||||
}
|
||||
if uses.contains("\\all") || value.contains("all mail") || value.contains("alle nachrichten") {
|
||||
return "tray.full"
|
||||
}
|
||||
if uses.contains("\\flagged") || value.contains("flagged") || value.contains("markiert") {
|
||||
return "flag"
|
||||
}
|
||||
if uses.contains("\\important") || value.contains("important") || value.contains("wichtig") {
|
||||
return "star"
|
||||
}
|
||||
return "folder"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +90,7 @@ struct MailMessage: Identifiable, Sendable {
|
||||
let attachments: [MailAttachment]
|
||||
var isRead = false
|
||||
var cc = ""
|
||||
var bcc = ""
|
||||
var replyTo = ""
|
||||
var htmlBody: String? = nil
|
||||
var displaySender: String { MailDisplay.sender(sender) }
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
enum MailPrinter {
|
||||
static func print(_ message: MailMessage) {
|
||||
guard let attributedMessage = makeAttributedMessage(message) else {
|
||||
NSSound.beep()
|
||||
return
|
||||
}
|
||||
|
||||
let printInfo = (NSPrintInfo.shared.copy() as? NSPrintInfo) ?? NSPrintInfo.shared
|
||||
printInfo.horizontalPagination = .fit
|
||||
printInfo.verticalPagination = .automatic
|
||||
|
||||
let printableWidth = max(
|
||||
printInfo.paperSize.width - printInfo.leftMargin - printInfo.rightMargin,
|
||||
400
|
||||
)
|
||||
let printableHeight = max(
|
||||
printInfo.paperSize.height - printInfo.topMargin - printInfo.bottomMargin,
|
||||
400
|
||||
)
|
||||
let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: printableWidth, height: 1))
|
||||
textView.isEditable = false
|
||||
textView.isSelectable = false
|
||||
textView.isRichText = true
|
||||
textView.drawsBackground = false
|
||||
textView.isHorizontallyResizable = false
|
||||
textView.isVerticallyResizable = true
|
||||
textView.textContainer?.widthTracksTextView = true
|
||||
textView.textContainer?.containerSize = NSSize(
|
||||
width: printableWidth,
|
||||
height: .greatestFiniteMagnitude
|
||||
)
|
||||
textView.textStorage?.setAttributedString(attributedMessage)
|
||||
if let textContainer = textView.textContainer {
|
||||
textView.layoutManager?.ensureLayout(for: textContainer)
|
||||
let contentHeight = textView.layoutManager?.usedRect(for: textContainer).height ?? 1
|
||||
let requiredHeight = max(contentHeight + 24, printableHeight)
|
||||
let pageCount = ceil(requiredHeight / printableHeight)
|
||||
textView.frame.size.height = pageCount * printableHeight
|
||||
}
|
||||
|
||||
let operation = NSPrintOperation(view: textView, printInfo: printInfo)
|
||||
operation.showsPrintPanel = true
|
||||
operation.showsProgressPanel = true
|
||||
operation.run()
|
||||
}
|
||||
|
||||
private static func makeAttributedMessage(_ message: MailMessage) -> NSAttributedString? {
|
||||
let body: String
|
||||
if let htmlBody = message.htmlBody, !htmlBody.isEmpty {
|
||||
body = htmlBody
|
||||
} else {
|
||||
body = "<div style=\"white-space: pre-wrap;\">\(escaped(message.body))</div>"
|
||||
}
|
||||
|
||||
let ccRow = message.cc.isEmpty
|
||||
? ""
|
||||
: "<tr><td style=\"font-weight: bold; width: 58px;\">CC:</td><td>\(escaped(message.cc))</td></tr>"
|
||||
let html = """
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8"></head>
|
||||
<body style="font-family: -apple-system; font-size: 10.5pt; color: #000; margin: 0;">
|
||||
<table cellspacing="0" cellpadding="0" style="font-size: 10.5pt; line-height: 1.3;">
|
||||
<tr><td class="label" style="font-weight: bold; width: 58px;">Betreff:</td><td style="font-size: 13pt; font-weight: bold;">\(escaped(message.subject))</td></tr>
|
||||
<tr><td class="label" style="font-weight: bold; width: 58px;">Von:</td><td>\(escaped(message.sender))</td></tr>
|
||||
<tr><td class="label" style="font-weight: bold; width: 58px;">An:</td><td>\(escaped(message.recipient))</td></tr>
|
||||
\(ccRow)
|
||||
<tr><td class="label" style="font-weight: bold; width: 58px;">Datum:</td><td>\(escaped(message.date.formatted(date: .long, time: .shortened)))</td></tr>
|
||||
</table>
|
||||
<hr size="1" color="#999999" style="margin: 12px 0 24px 0;">
|
||||
<div style="font-size: 10.5pt; line-height: 1.35;">\(body)</div>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
return try? NSAttributedString(
|
||||
data: Data(html.utf8),
|
||||
options: [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
],
|
||||
documentAttributes: nil
|
||||
)
|
||||
}
|
||||
|
||||
private static func escaped(_ text: String) -> String {
|
||||
text
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
}
|
||||
}
|
||||
+26
-19
@@ -38,10 +38,10 @@ struct MailSettings: View {
|
||||
@AppStorage("blockRemoteContent") private var blockRemoteContent = true
|
||||
private let fontNames = NSFontManager.shared.availableFontFamilies.sorted()
|
||||
@AppStorage("mailDeleteConfirmation") private var deleteConfirmation: MailDeleteConfirmation = .always
|
||||
@AppStorage("idleEnabled") private var idleEnabled = true
|
||||
@AppStorage("pollMinutes") private var pollMinutes = 5
|
||||
@AppStorage("offerTestMail") private var offerTestMail = true
|
||||
@AppStorage("checkOnLaunch") private var checkOnLaunch = true
|
||||
@AppStorage(AttachmentMentionDetector.settingKey) private var warnMissingAttachment = true
|
||||
@AppStorage(MailNotifications.enabledKey) private var newMailNotifications = false
|
||||
@AppStorage("showUnifiedInbox") private var showInbox = true
|
||||
@AppStorage("showUnifiedTrash") private var showTrash = true
|
||||
|
||||
@@ -59,17 +59,17 @@ struct MailSettings: View {
|
||||
}
|
||||
Tab("Abruf", systemImage: "arrow.clockwise") {
|
||||
RetrievalSettingsTab(
|
||||
idleEnabled: $idleEnabled,
|
||||
pollMinutes: $pollMinutes,
|
||||
offerTestMail: $offerTestMail,
|
||||
checkOnLaunch: $checkOnLaunch
|
||||
checkOnLaunch: $checkOnLaunch,
|
||||
newMailNotifications: $newMailNotifications
|
||||
)
|
||||
}
|
||||
Tab("Verhalten", systemImage: "slider.horizontal.3") {
|
||||
BehaviorSettingsTab(
|
||||
markReadImmediately: $markReadImmediately,
|
||||
markReadDelaySeconds: $markReadDelaySeconds,
|
||||
deleteConfirmation: $deleteConfirmation
|
||||
deleteConfirmation: $deleteConfirmation,
|
||||
warnMissingAttachment: $warnMissingAttachment
|
||||
)
|
||||
}
|
||||
Tab("Sicherung", systemImage: "externaldrive") {
|
||||
@@ -184,27 +184,28 @@ private struct DisplaySettingsTab: View {
|
||||
}
|
||||
|
||||
private struct RetrievalSettingsTab: View {
|
||||
@Binding var idleEnabled: Bool
|
||||
@Binding var pollMinutes: Int
|
||||
@Binding var offerTestMail: Bool
|
||||
@Binding var checkOnLaunch: Bool
|
||||
@Binding var newMailNotifications: 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("Mitteilungen") {
|
||||
Toggle("Bei neuen Mails benachrichtigen", isOn: $newMailNotifications)
|
||||
.onChange(of: newMailNotifications) { _, enabled in
|
||||
guard enabled else { return }
|
||||
Task {
|
||||
if await !MailNotifications.requestAuthorization() {
|
||||
newMailNotifications = false
|
||||
}
|
||||
}
|
||||
}
|
||||
Text("Die Anzahl ungelesener Mails wird immer am App-Symbol angezeigt.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Section("Neue Konten") {
|
||||
Toggle("Nach neuem Konto Testmail anbieten", isOn: $offerTestMail)
|
||||
}
|
||||
@@ -218,6 +219,7 @@ private struct BehaviorSettingsTab: View {
|
||||
@Binding var markReadImmediately: Bool
|
||||
@Binding var markReadDelaySeconds: Int
|
||||
@Binding var deleteConfirmation: MailDeleteConfirmation
|
||||
@Binding var warnMissingAttachment: Bool
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
@@ -230,6 +232,11 @@ private struct BehaviorSettingsTab: View {
|
||||
Stepper("Nach \(markReadDelaySeconds) Sekunden", value: $markReadDelaySeconds, in: 1...3600)
|
||||
}
|
||||
}
|
||||
Section("Verfassen") {
|
||||
Toggle("Vor möglicherweise vergessenem Anhang warnen", isOn: $warnMissingAttachment)
|
||||
Text("Prüft Betreff und Nachrichtentext vor dem Senden auf Hinweise wie „im Anhang“, „anbei“ oder „beigefügt“.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Section("Löschen") {
|
||||
Picker("Beim Löschen von Mails nachfragen", selection: $deleteConfirmation) {
|
||||
Text("Immer").tag(MailDeleteConfirmation.always)
|
||||
|
||||
+79
-25
@@ -21,7 +21,6 @@ final class MailStore {
|
||||
private var selectedFolderID: String?
|
||||
private var didStart = false
|
||||
private let idleMonitor = MailIdleMonitor()
|
||||
private var idleEnabled = false
|
||||
private var pendingIdleAccounts: Set<UUID> = []
|
||||
private var idleRefreshGeneration = UUID()
|
||||
private var idleRefreshTask: Task<Void, Never>?
|
||||
@@ -88,7 +87,7 @@ final class MailStore {
|
||||
validatedAccounts[account.id] = nil
|
||||
validatedPasswords[account.id] = nil
|
||||
status = "Konto lokal gespeichert."
|
||||
configureIdle(enabled: idleEnabled)
|
||||
configureIdle()
|
||||
idleMonitor.restart(accountID: account.id)
|
||||
}
|
||||
|
||||
@@ -100,7 +99,7 @@ final class MailStore {
|
||||
remainingFolders[id] = nil
|
||||
try archive.saveConfiguration(ArchiveConfiguration(accounts: remaining, folders: remainingFolders, recipients: recipientHistory))
|
||||
accounts = remaining
|
||||
configureIdle(enabled: idleEnabled)
|
||||
configureIdle()
|
||||
folders = remainingFolders
|
||||
messages = []
|
||||
totalMessages = 0
|
||||
@@ -131,11 +130,19 @@ final class MailStore {
|
||||
items.flatMap { [$0] + allFolders($0.children ?? []) }
|
||||
}
|
||||
|
||||
var totalUnreadCount: Int {
|
||||
accounts
|
||||
.flatMap { allFolders(folders[$0.id] ?? []) }
|
||||
.filter(\.isInbox)
|
||||
.reduce(0) { $0 + (unreadCounts[$1.id] ?? 0) }
|
||||
}
|
||||
|
||||
func refresh(folderID: String?, accountID: UUID? = nil, inboxOnly: Bool = false) async {
|
||||
guard !isBusy else { return }
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
var failures: [String] = []
|
||||
var newInboxMessages: [NewMailNotification] = []
|
||||
do {
|
||||
let archive = try storage()
|
||||
for account in accounts where accountID == nil || account.id == accountID {
|
||||
@@ -186,6 +193,13 @@ final class MailStore {
|
||||
try Task.checkCancellation()
|
||||
status = "\(folder.name): \(index + 1) von \(missing.count) neuen Mails speichern …"
|
||||
let raw = try await client.rawMessage(uid: uid)
|
||||
if inboxOnly && folder.isInbox {
|
||||
let headers = MIME.split(raw).0
|
||||
newInboxMessages.append(NewMailNotification(
|
||||
sender: MailDisplay.sender(MIME.decodeHeader(headers["from"] ?? "")),
|
||||
subject: MIME.decodeHeader(headers["subject"] ?? "(Ohne Betreff)")
|
||||
))
|
||||
}
|
||||
try archive.store(raw: raw, folderID: folder.id, validity: inventory.validity,
|
||||
uid: String(uid), flags: inventory.flags[uid] ?? [], visible: false)
|
||||
}
|
||||
@@ -201,6 +215,10 @@ final class MailStore {
|
||||
unreadCounts = try archive.unreadCounts()
|
||||
archiveRevision += 1
|
||||
if let selectedFolderID { try display(folderID: selectedFolderID) }
|
||||
await MailNotifications.postNewMail(
|
||||
newInboxMessages,
|
||||
badgeCount: totalUnreadCount
|
||||
)
|
||||
} catch { failures.append(error.localizedDescription) }
|
||||
if failures.isEmpty {
|
||||
status = "Abgleich abgeschlossen. Alle abgerufenen Mails sind offline verfügbar."
|
||||
@@ -244,7 +262,7 @@ final class MailStore {
|
||||
let copies = Self.addresses(cc)
|
||||
let blindCopies = Self.addresses(bcc)
|
||||
let envelope = Array(Set(addresses + copies + blindCopies)).sorted()
|
||||
let data = try MIME.compose(from: account.email, to: addresses, cc: copies, bcc: blindCopies, subject: subject, body: body, attachments: attachments, htmlBody: htmlBody)
|
||||
let data = try MIME.compose(from: account.email, senderName: account.displayName, to: addresses, cc: copies, bcc: blindCopies, subject: subject, body: body, attachments: attachments, htmlBody: htmlBody)
|
||||
let localFolder = MailFolder(accountID: account.id, path: "__local_outgoing__", name: "Lokale Versandkopien")
|
||||
let localID = UUID().uuidString
|
||||
// Persist BEFORE SMTP, so a crash or uncertain delivery cannot lose the composed message.
|
||||
@@ -266,6 +284,58 @@ final class MailStore {
|
||||
}
|
||||
}
|
||||
|
||||
func saveDraft(accountID: UUID?, recipients: String, cc: String, bcc: String, subject: String,
|
||||
body: String, attachments: [MailAttachment], htmlBody: String?) async throws {
|
||||
guard let account = accounts.first(where: { $0.id == accountID }) else {
|
||||
throw MailError.message("Bitte ein Absenderkonto auswählen.")
|
||||
}
|
||||
guard !sendingAccountIDs.contains(account.id) else {
|
||||
throw MailError.message("Für dieses Konto läuft bereits ein Vorgang.")
|
||||
}
|
||||
sendingAccountIDs.insert(account.id)
|
||||
defer { sendingAccountIDs.remove(account.id) }
|
||||
|
||||
let client = try IMAPClient(account: account)
|
||||
defer { client.close() }
|
||||
try await client.login(account: account, password: PasswordStore.password(for: account.id))
|
||||
|
||||
var discovered = try await client.folders(accountID: account.id)
|
||||
var draftsFolder = allFolders(discovered).first { $0.specialUse?.contains("\\drafts") == true }
|
||||
?? allFolders(discovered).first {
|
||||
["drafts", "entwürfe", "entwuerfe"].contains($0.name.lowercased())
|
||||
}
|
||||
if draftsFolder == nil {
|
||||
try await client.createFolder(path: "Drafts")
|
||||
discovered = try await client.folders(accountID: account.id)
|
||||
draftsFolder = allFolders(discovered).first { $0.specialUse?.contains("\\drafts") == true }
|
||||
?? allFolders(discovered).first { $0.path == "Drafts" }
|
||||
}
|
||||
guard let draftsFolder else {
|
||||
throw MailError.message("Der Entwürfe-Ordner konnte nicht gefunden oder erstellt werden.")
|
||||
}
|
||||
|
||||
let data = try MIME.compose(
|
||||
from: account.email,
|
||||
senderName: account.displayName,
|
||||
to: Self.addresses(recipients),
|
||||
cc: Self.addresses(cc),
|
||||
bcc: Self.addresses(bcc),
|
||||
subject: subject,
|
||||
body: body,
|
||||
attachments: attachments,
|
||||
htmlBody: htmlBody,
|
||||
allowEmptyRecipients: true,
|
||||
includeBccHeader: true
|
||||
)
|
||||
try await client.append(data, path: draftsFolder.path, flags: ["\\Draft"])
|
||||
|
||||
folders[account.id] = discovered
|
||||
try persist()
|
||||
status = "Entwurf gespeichert."
|
||||
while isBusy { try await Task.sleep(for: .milliseconds(200)) }
|
||||
await refresh(folderID: selectedFolderID, accountID: account.id)
|
||||
}
|
||||
|
||||
func start(folderID: String?) async {
|
||||
await load(folderID: folderID)
|
||||
guard !didStart else { return }
|
||||
@@ -536,25 +606,9 @@ final class MailStore {
|
||||
}
|
||||
}
|
||||
|
||||
func poll(intervalMinutes: Int) async {
|
||||
guard intervalMinutes > 0 else { return }
|
||||
while !Task.isCancelled {
|
||||
do { try await Task.sleep(for: .seconds(max(1, intervalMinutes) * 60)) }
|
||||
catch { return }
|
||||
await refresh(folderID: selectedFolderID)
|
||||
}
|
||||
}
|
||||
|
||||
func configureIdle(enabled: Bool) {
|
||||
idleEnabled = enabled
|
||||
if !enabled {
|
||||
pendingIdleAccounts.removeAll()
|
||||
idleRefreshGeneration = UUID()
|
||||
idleRefreshTask?.cancel()
|
||||
idleRefreshTask = nil
|
||||
idleStatuses = [:]
|
||||
} else { idleStatuses = idleStatuses.filter { id, _ in accounts.contains { $0.id == id } } }
|
||||
idleMonitor.configure(accounts: accounts, enabled: enabled) { [weak self] id in
|
||||
func configureIdle() {
|
||||
idleStatuses = idleStatuses.filter { id, _ in accounts.contains { $0.id == id } }
|
||||
idleMonitor.configure(accounts: accounts, enabled: true) { [weak self] id in
|
||||
self?.scheduleIdleRefresh(accountID: id)
|
||||
} report: { [weak self] id, status in
|
||||
self?.idleStatuses[id] = status
|
||||
@@ -564,7 +618,7 @@ final class MailStore {
|
||||
}
|
||||
|
||||
private func scheduleIdleRefresh(accountID: UUID) {
|
||||
guard idleEnabled, accounts.contains(where: { $0.id == accountID }) else { return }
|
||||
guard accounts.contains(where: { $0.id == accountID }) else { return }
|
||||
pendingIdleAccounts.insert(accountID)
|
||||
guard idleRefreshTask == nil else { return }
|
||||
let generation = UUID()
|
||||
@@ -574,7 +628,7 @@ final class MailStore {
|
||||
defer { if self.idleRefreshGeneration == generation { self.idleRefreshTask = nil } }
|
||||
// Coalesce bursts and retain notifications arriving during another synchronization.
|
||||
try? await Task.sleep(for: .milliseconds(400))
|
||||
while !Task.isCancelled, self.idleEnabled, !self.pendingIdleAccounts.isEmpty {
|
||||
while !Task.isCancelled, !self.pendingIdleAccounts.isEmpty {
|
||||
if self.isBusy {
|
||||
do { try await Task.sleep(for: .milliseconds(200)) } catch { return }
|
||||
continue
|
||||
|
||||
@@ -8,11 +8,27 @@ struct ComposeDraft: Identifiable {
|
||||
let id = UUID()
|
||||
var accountID: UUID?
|
||||
var recipient = ""
|
||||
var cc = ""
|
||||
var bcc = ""
|
||||
var subject = ""
|
||||
var body = ""
|
||||
var htmlBody: String?
|
||||
var attachments: [MailAttachment] = []
|
||||
var isReply = false
|
||||
|
||||
static func saved(_ message: MailMessage, accountID: UUID?) -> ComposeDraft {
|
||||
ComposeDraft(
|
||||
accountID: accountID,
|
||||
recipient: message.recipient,
|
||||
cc: message.cc,
|
||||
bcc: message.bcc,
|
||||
subject: message.subject,
|
||||
body: message.body,
|
||||
htmlBody: message.htmlBody,
|
||||
attachments: message.attachments
|
||||
)
|
||||
}
|
||||
|
||||
static func from(_ message: MailMessage, accountID: UUID?, forwarding: Bool) -> ComposeDraft {
|
||||
let source = message.replyTo.isEmpty ? message.sender : message.replyTo
|
||||
let recipient: String
|
||||
@@ -55,6 +71,12 @@ struct MessageDetailWindow: View {
|
||||
.frame(minWidth: 600, minHeight: 450)
|
||||
.navigationTitle(message?.subject ?? "Nachricht")
|
||||
.toolbar {
|
||||
Button("Drucken …", systemImage: "printer") {
|
||||
guard let message else { return }
|
||||
MailPrinter.print(message)
|
||||
}
|
||||
.disabled(message == nil)
|
||||
|
||||
MessageActionButtons { command in
|
||||
guard let message else { return }
|
||||
switch command {
|
||||
|
||||
@@ -4,6 +4,9 @@ import SwiftUI
|
||||
struct MikeMailApp: App {
|
||||
@State private var store: MailStore = {
|
||||
SettingsBackupManager.applyPendingRestoreIfNeeded()
|
||||
UserDefaults.standard.removeObject(forKey: "idleEnabled")
|
||||
UserDefaults.standard.removeObject(forKey: "pollMinutes")
|
||||
MailNotifications.configure()
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.arguments.contains("--ui-fixture") { return MailStore.uiFixture() }
|
||||
#endif
|
||||
|
||||
@@ -5,7 +5,7 @@ import ContactsUI
|
||||
|
||||
enum RecipientCompletion {
|
||||
static func matches(in history: [String], input: String) -> [String] {
|
||||
let parts = input.components(separatedBy: ",")
|
||||
let parts = input.components(separatedBy: CharacterSet(charactersIn: ",;"))
|
||||
let query = (parts.last ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !query.isEmpty else { return [] }
|
||||
let selected = Set(parts.dropLast().map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() })
|
||||
@@ -16,11 +16,11 @@ enum RecipientCompletion {
|
||||
}
|
||||
|
||||
static func inserting(_ address: String, into input: String) -> String {
|
||||
var parts = input.components(separatedBy: ",").dropLast().map {
|
||||
var parts = input.components(separatedBy: CharacterSet(charactersIn: ",;")).dropLast().map {
|
||||
$0.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}.filter { !$0.isEmpty }
|
||||
if !parts.contains(where: { $0.caseInsensitiveCompare(address) == .orderedSame }) { parts.append(address) }
|
||||
return parts.joined(separator: ", ") + ", "
|
||||
return parts.joined(separator: "; ") + "; "
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ struct RecipientField: View {
|
||||
recipient = RecipientCompletion.inserting(address, into: recipient)
|
||||
focused = true
|
||||
}.frame(width: 30, height: 26)
|
||||
TextField("An (mehrere mit Komma trennen)", text: $recipient)
|
||||
TextField("An (mehrere mit Semikolon trennen)", text: $recipient)
|
||||
.accessibilityIdentifier("recipientField")
|
||||
.focused($focused)
|
||||
.onKeyPress(.downArrow) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// Intercepts the native close button while preserving SwiftUI's existing window delegate.
|
||||
struct WindowCloseGuard: NSViewRepresentable {
|
||||
let shouldClose: () -> Bool
|
||||
let onCloseAttempt: () -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(shouldClose: shouldClose, onCloseAttempt: onCloseAttempt)
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView()
|
||||
DispatchQueue.main.async {
|
||||
context.coordinator.attach(to: view.window)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ view: NSView, context: Context) {
|
||||
context.coordinator.shouldClose = shouldClose
|
||||
context.coordinator.onCloseAttempt = onCloseAttempt
|
||||
DispatchQueue.main.async {
|
||||
context.coordinator.attach(to: view.window)
|
||||
}
|
||||
}
|
||||
|
||||
static func dismantleNSView(_ view: NSView, coordinator: Coordinator) {
|
||||
coordinator.detach()
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, NSWindowDelegate {
|
||||
var shouldClose: () -> Bool
|
||||
var onCloseAttempt: () -> Void
|
||||
weak var window: NSWindow?
|
||||
weak var previousDelegate: NSWindowDelegate?
|
||||
|
||||
init(shouldClose: @escaping () -> Bool, onCloseAttempt: @escaping () -> Void) {
|
||||
self.shouldClose = shouldClose
|
||||
self.onCloseAttempt = onCloseAttempt
|
||||
}
|
||||
|
||||
func attach(to window: NSWindow?) {
|
||||
guard let window else { return }
|
||||
if self.window === window, window.delegate === self { return }
|
||||
detach()
|
||||
self.window = window
|
||||
previousDelegate = window.delegate
|
||||
window.delegate = self
|
||||
}
|
||||
|
||||
func detach() {
|
||||
if window?.delegate === self {
|
||||
window?.delegate = previousDelegate
|
||||
}
|
||||
window = nil
|
||||
previousDelegate = nil
|
||||
}
|
||||
|
||||
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||
guard shouldClose() else {
|
||||
onCloseAttempt()
|
||||
return false
|
||||
}
|
||||
return previousDelegate?.windowShouldClose?(sender) ?? true
|
||||
}
|
||||
|
||||
override func responds(to selector: Selector!) -> Bool {
|
||||
super.responds(to: selector) || previousDelegate?.responds(to: selector) == true
|
||||
}
|
||||
|
||||
override func forwardingTarget(for selector: Selector!) -> Any? {
|
||||
if previousDelegate?.responds(to: selector) == true {
|
||||
return previousDelegate
|
||||
}
|
||||
return super.forwardingTarget(for: selector)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,5 @@ struct MailIdleTests {
|
||||
#expect(store.authenticationAccount == nil)
|
||||
store.requestPassword(accountID: UUID())
|
||||
#expect(store.authenticationAccount == nil)
|
||||
store.configureIdle(enabled: false)
|
||||
#expect(store.idleStatuses.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user