diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..61bf205 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +## Project Guidelines + +- After completing changes, build and install the updated app for manual testing. +- Do not call the task done until it is fully complete and tested. +- Do not dismiss bug as a pre-existing" issue even if it was present before your change. It does not matter, it's still your responsibility to fix it. When you see a bug, fix it. Don't ignore it. + +## Coding Guidelines + + - Keep code simple and easy to read. + - Avoid excessive comments. Only comment when absolutely necessary. Code should be readable and not require comments to understand it. + +## Git Commit Rules + + - Do not make branches, commit in main only + - Commit message is one plain lowercase line. No type prefix, no scope, no body. + - Never use `git add .` or `git add -A`. Always stage specific files by name. + - Don't batch multiple unrelated changes into one commit. + diff --git a/justfile b/justfile index 5c31a07..080a10c 100644 --- a/justfile +++ b/justfile @@ -53,7 +53,14 @@ build: fi fi case "$(uname -s)" in - Darwin) pnpm tauri build --bundles app ;; + Darwin) + autofill_config=$(node scripts/prepare-autofill.mjs) + if [ -n "$autofill_config" ]; then + pnpm tauri build --bundles app --config "$autofill_config" + else + pnpm tauri build --bundles app + fi + ;; Linux) pnpm tauri build --bundles deb,appimage ;; *) echo "just: no local build for $(uname -s); macOS and Linux are the desktop targets." >&2; exit 1 ;; esac diff --git a/native/autofill/CredentialProvider.swift b/native/autofill/CredentialProvider.swift new file mode 100644 index 0000000..84a4f44 --- /dev/null +++ b/native/autofill/CredentialProvider.swift @@ -0,0 +1,158 @@ +import AppKit +import AuthenticationServices + +private struct EmailCode: Decodable { + let id: String + let code: String + let domain: String + let label: String + let expiresAtMs: Double + + static func available() -> [EmailCode] { + guard let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: "TQV87WLXK3.studio.margin.mail" + ), + let data = try? Data(contentsOf: container.appendingPathComponent("email-otp.json")), + let codes = try? JSONDecoder().decode([EmailCode].self, from: data) else { + return [] + } + let now = Date().timeIntervalSince1970 * 1000 + return codes.filter { + !$0.id.isEmpty && !$0.code.isEmpty && $0.code.count <= 32 + && !$0.domain.isEmpty && $0.expiresAtMs > now + && $0.expiresAtMs <= now + 180_000 + }.sorted { $0.expiresAtMs > $1.expiresAtMs } + } + + func matches(_ service: ASCredentialServiceIdentifier) -> Bool { + let host: String + if service.type == .URL { + guard let urlHost = URL(string: service.identifier)?.host else { return false } + host = urlHost.lowercased() + } else if service.type == .domain { + host = service.identifier.lowercased() + } else { + return false + } + let senderDomain = domain.lowercased() + return host == senderDomain || host.hasSuffix("." + senderDomain) + } +} + +@available(macOS 15.0, *) +final class CredentialProviderViewController: ASCredentialProviderViewController { + override func loadView() { + view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 340)) + } + + override func provideCredentialWithoutUserInteraction(for credentialRequest: any ASCredentialRequest) { + provide(credentialRequest) + } + + override func prepareInterfaceToProvideCredential(for credentialRequest: any ASCredentialRequest) { + provide(credentialRequest) + } + + override func prepareOneTimeCodeCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) { + let codes = EmailCode.available() + let matching = codes.filter { code in serviceIdentifiers.contains { code.matches($0) } } + let others = codes.filter { code in !serviceIdentifiers.contains { code.matches($0) } } + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 12 + stack.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + stack.translatesAutoresizingMaskIntoConstraints = false + + let title = NSTextField(labelWithString: "Email verification codes") + title.font = .boldSystemFont(ofSize: 17) + stack.addArrangedSubview(title) + + if codes.isEmpty { + let empty = NSTextField(wrappingLabelWithString: + "No recent codes are available. Enable Email OTP AutoFill in Margin Mail and keep the app unlocked." + ) + stack.addArrangedSubview(empty) + } else { + add(matching, to: stack) + if !others.isEmpty { + if !serviceIdentifiers.isEmpty { + let label = NSTextField(wrappingLabelWithString: + "Other email codes. Select only the code for the website you are using." + ) + label.textColor = .secondaryLabelColor + stack.addArrangedSubview(label) + } + add(others, to: stack) + } + } + + let cancel = NSButton(title: "Cancel", target: self, action: #selector(cancelSelection)) + cancel.bezelStyle = .rounded + stack.addArrangedSubview(cancel) + + let scroll = NSScrollView() + scroll.hasVerticalScroller = true + scroll.drawsBackground = false + scroll.translatesAutoresizingMaskIntoConstraints = false + scroll.documentView = stack + view.subviews.forEach { $0.removeFromSuperview() } + view.addSubview(scroll) + NSLayoutConstraint.activate([ + scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scroll.topAnchor.constraint(equalTo: view.topAnchor), + scroll.bottomAnchor.constraint(equalTo: view.bottomAnchor), + stack.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor), + stack.topAnchor.constraint(equalTo: scroll.contentView.topAnchor), + ]) + } + + private func add(_ codes: [EmailCode], to stack: NSStackView) { + for code in codes { + let button = NSButton( + title: "\(code.code) · \(code.label)", + target: self, + action: #selector(selectCode(_:)) + ) + button.identifier = NSUserInterfaceItemIdentifier(code.id) + button.bezelStyle = .rounded + button.toolTip = code.domain + stack.addArrangedSubview(button) + let domain = NSTextField(labelWithString: code.domain) + domain.font = .systemFont(ofSize: 11) + domain.textColor = .secondaryLabelColor + stack.addArrangedSubview(domain) + } + } + + private func provide(_ request: any ASCredentialRequest) { + guard request.type == .oneTimeCode, + let identity = request.credentialIdentity as? ASOneTimeCodeCredentialIdentity, + let id = identity.recordIdentifier, + let code = EmailCode.available().first(where: { $0.id == id }), + code.matches(identity.serviceIdentifier) else { + cancel(with: .credentialIdentityNotFound) + return + } + extensionContext.completeOneTimeCodeRequest(using: ASOneTimeCodeCredential(code: code.code)) + } + + @objc private func selectCode(_ sender: NSButton) { + guard let id = sender.identifier?.rawValue, + let code = EmailCode.available().first(where: { $0.id == id }) else { + cancel(with: .credentialIdentityNotFound) + return + } + extensionContext.completeOneTimeCodeRequest(using: ASOneTimeCodeCredential(code: code.code)) + } + + @objc private func cancelSelection() { + cancel(with: .userCanceled) + } + + private func cancel(with code: ASExtensionError.Code) { + extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: code.rawValue)) + } +} diff --git a/native/autofill/Info.plist b/native/autofill/Info.plist new file mode 100644 index 0000000..8a98e6f --- /dev/null +++ b/native/autofill/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Margin Mail + CFBundleExecutable + MarginMailAutoFill + CFBundleIdentifier + studio.margin.mail.autofill + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Margin Mail AutoFill + CFBundlePackageType + XPC! + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 15.0 + NSExtension + + NSExtensionPointIdentifier + com.apple.authentication-services-credential-provider-ui + NSExtensionPrincipalClass + MarginMailAutoFill.CredentialProviderViewController + NSExtensionAttributes + + ASCredentialProviderExtensionCapabilities + + ProvidesOneTimeCodes + + ProvidesPasswords + + ProvidesPasskeys + + + + + + diff --git a/native/autofill/autofill.entitlements b/native/autofill/autofill.entitlements new file mode 100644 index 0000000..c28e7a4 --- /dev/null +++ b/native/autofill/autofill.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.developer.authentication-services.autofill-credential-provider + + com.apple.security.application-groups + + TQV87WLXK3.studio.margin.mail + + + diff --git a/native/autofill/host.entitlements b/native/autofill/host.entitlements new file mode 100644 index 0000000..d7df789 --- /dev/null +++ b/native/autofill/host.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.developer.authentication-services.autofill-credential-provider + + com.apple.security.application-groups + + TQV87WLXK3.studio.margin.mail + + + diff --git a/package.json b/package.json index 6b38699..6c1dda9 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "@tauri-apps/plugin-os": "^2", "@tauri-apps/plugin-process": "^2", "@tauri-apps/plugin-updater": "^2", + "@tiptap/core": "^3.31.2", + "@tiptap/pm": "^3.31.2", "@tiptap/react": "^3.31.2", "@tiptap/starter-kit": "^3.31.2", "margin-shared": "file:../../python/margin/shared", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 044a156..757d16e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,12 @@ importers: '@tauri-apps/plugin-updater': specifier: ^2 version: 2.11.0 + '@tiptap/core': + specifier: ^3.31.2 + version: 3.31.2(@tiptap/pm@3.31.2) + '@tiptap/pm': + specifier: ^3.31.2 + version: 3.31.2 '@tiptap/react': specifier: ^3.31.2 version: 3.31.2(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.2(@tiptap/pm@3.31.2))(@tiptap/pm@3.31.2)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) diff --git a/scripts/prepare-autofill.mjs b/scripts/prepare-autofill.mjs new file mode 100644 index 0000000..fff91a0 --- /dev/null +++ b/scripts/prepare-autofill.mjs @@ -0,0 +1,70 @@ +import { execFileSync } from "node:child_process"; +import { access, copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +const signing = process.env.MARGIN_SIGNING_DIR || join(homedir(), ".margin-signing"); +const appProfile = join(signing, "studio.margin.mail.provisionprofile"); +const extensionProfile = join(signing, "studio.margin.mail.autofill.provisionprofile"); +const present = await Promise.all([appProfile, extensionProfile].map(async (path) => { + try { await access(path); return true; } catch (error) { + if (error.code === "ENOENT") return false; + throw error; + } +})); +if (!present.every(Boolean)) { + console.error("Email OTP AutoFill is unavailable: both Margin Mail provisioning profiles are required."); + process.exit(0); +} +const identity = process.env.APPLE_SIGNING_IDENTITY; +if (!identity || identity === "-") throw new Error("Email OTP AutoFill requires a Developer ID signing identity."); +const plist = (path) => JSON.parse(execFileSync("plutil", ["-convert", "json", "-o", "-", path], { encoding: "utf8" })); +const profile = (path, identifier) => { + const xml = execFileSync("security", ["cms", "-D", "-i", path]); + const entitlements = JSON.parse(execFileSync("plutil", ["-extract", "Entitlements", "json", "-o", "-", "-"], { input: xml, encoding: "utf8" })); + const expiry = execFileSync("plutil", ["-extract", "ExpirationDate", "raw", "-o", "-", "-"], { input: xml, encoding: "utf8" }).trim(); + if (entitlements["com.apple.application-identifier"] !== `TQV87WLXK3.${identifier}` || + entitlements["com.apple.developer.team-identifier"] !== "TQV87WLXK3" || + entitlements["com.apple.developer.authentication-services.autofill-credential-provider"] !== true || + !entitlements["com.apple.security.application-groups"]?.some((group) => + group === "TQV87WLXK3.*" || group === "TQV87WLXK3.studio.margin.mail") || + !(new Date(expiry).getTime() > Date.now())) { + throw new Error(`Invalid or expired AutoFill profile for ${identifier}.`); + } + return entitlements; +}; +const appEntitlements = profile(appProfile, "studio.margin.mail"); +const extensionEntitlements = profile(extensionProfile, "studio.margin.mail.autofill"); +const metadata = JSON.parse(execFileSync("cargo", ["metadata", "--no-deps", "--format-version", "1", "--manifest-path", "src-tauri/Cargo.toml"], { encoding: "utf8" })); +const output = join(metadata.target_directory, ".tauri", "autofill"); +const contents = join(output, "MarginMailAutoFill.appex", "Contents"); +await mkdir(join(contents, "MacOS"), { recursive: true }); +const config = JSON.parse(await readFile("src-tauri/tauri.conf.json", "utf8")); +const info = plist("native/autofill/Info.plist"); +info.CFBundleShortVersionString = config.version; +info.CFBundleVersion = config.bundle.macOS.bundleVersion || config.version; +const writePlist = async (path, value) => { + await writeFile(path, JSON.stringify(value)); + execFileSync("plutil", ["-convert", "xml1", path]); +}; +await writePlist(join(contents, "Info.plist"), info); +await copyFile(extensionProfile, join(contents, "embedded.provisionprofile")); +for (const [name, authorized] of [["host", appEntitlements], ["extension", extensionEntitlements]]) { + const value = plist(name === "host" ? "native/autofill/host.entitlements" : "native/autofill/autofill.entitlements"); + value["com.apple.application-identifier"] = authorized["com.apple.application-identifier"]; + value["com.apple.developer.team-identifier"] = authorized["com.apple.developer.team-identifier"]; + await writePlist(join(output, `${name}.entitlements`), value); +} +const arch = process.arch === "arm64" ? "arm64" : "x86_64"; +execFileSync("xcrun", ["swiftc", "-O", "-parse-as-library", "-application-extension", "-module-name", "MarginMailAutoFill", + "-target", `${arch}-apple-macosx15.0`, "-Xlinker", "-e", "-Xlinker", "_NSExtensionMain", + "native/autofill/CredentialProvider.swift", "-o", join(contents, "MacOS", "MarginMailAutoFill")], { stdio: ["ignore", "ignore", "inherit"] }); +const extension = join(output, "MarginMailAutoFill.appex"); +execFileSync("codesign", ["--force", "--sign", identity, "--options", "runtime", "--timestamp", "--entitlements", join(output, "extension.entitlements"), extension], { stdio: ["ignore", "ignore", "inherit"] }); +execFileSync("codesign", ["--verify", "--strict", extension], { stdio: ["ignore", "ignore", "inherit"] }); +const overlay = join(output, "tauri.conf.json"); +await writeFile(overlay, JSON.stringify({ bundle: { macOS: { + entitlements: join(output, "host.entitlements"), + files: { "PlugIns/MarginMailAutoFill.appex": extension, "embedded.provisionprofile": resolve(appProfile) }, +} } })); +console.log(overlay); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c8a34c3..ff0e59c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2946,10 +2946,13 @@ dependencies = [ "mail-parser", "mail-send", "objc2", + "objc2-app-kit", "objc2-foundation", + "objc2-local-authentication", "objc2-ui-kit", "objc2-user-notifications", "rand 0.8.8", + "regex", "reqwest 0.13.1", "roxmltree 0.21.1", "rusqlite", @@ -3381,6 +3384,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-local-authentication" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48e0b8b339e0d9d2ed4416b7f93f9d4daadff7d4dd797f89867cde11aeac607" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-osa-kit" version = "0.3.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 81bb3bf..84bb629 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ base64 = "0.22" sha2 = "0.10" rand = "0.8" url = "2" +regex = "1" chrono = { version = "0.4", features = ["serde"] } tokio = { version = "1", features = ["sync", "time", "net", "io-util", "rt"] } @@ -109,14 +110,26 @@ roxmltree = "0.21" # build keeps a single copy of objc2. [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSSpellChecker", "NSApplication", "NSMenu", "NSMenuItem"] } objc2-foundation = { version = "0.3", default-features = false, features = [ "std", + "NSArray", + "NSRange", + "NSFileManager", + "NSURL", + "NSTextCheckingResult", + "NSValue", "NSDictionary", "NSError", "NSObject", "NSString", ] } +objc2-local-authentication = { version = "0.3", default-features = false, features = [ + "std", + "block2", + "LAContext", +] } objc2-user-notifications = { version = "0.3", default-features = false, features = [ "std", "block2", diff --git a/src-tauri/src/app_lock.rs b/src-tauri/src/app_lock.rs new file mode 100644 index 0000000..e9e94df --- /dev/null +++ b/src-tauri/src/app_lock.rs @@ -0,0 +1,136 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + +use serde::Serialize; +use tauri::Manager; + +pub struct AppLock { + enabled: AtomicBool, + unlocked: AtomicBool, +} + +impl AppLock { + pub fn new(enabled: bool) -> Self { + Self { + enabled: AtomicBool::new(enabled), + unlocked: AtomicBool::new(!enabled), + } + } + + pub fn configure(&self, enabled: bool) { + self.enabled.store(enabled, Ordering::Release); + } + + pub fn is_locked(&self) -> bool { + self.enabled.load(Ordering::Acquire) && !self.unlocked.load(Ordering::Acquire) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LockStatus { + enabled: bool, + locked: bool, + available: bool, + unavailable_reason: Option, +} + +pub fn permits(invoke: &tauri::ipc::Invoke) -> bool { + matches!(invoke.message.command(), "app_lock_status" | "app_unlock") + || !invoke.message.state_ref().get::().is_locked() +} + +pub fn is_locked(app: &tauri::AppHandle) -> bool { + app.try_state::() + .map(|lock| lock.is_locked()) + .unwrap_or(true) +} + +pub fn validate_setting_change(before: bool, after: bool) -> Result<(), String> { + if before != after { + authenticate(if after { + "Authenticate to require unlocking your mailbox when opening the app." + } else { + "Authenticate to turn off the mailbox opening lock." + })?; + } + Ok(()) +} + +#[tauri::command(async)] +pub fn app_lock_status(app: tauri::AppHandle) -> LockStatus { + let lock = app.state::(); + let unavailable_reason = availability().err(); + LockStatus { + enabled: lock.enabled.load(Ordering::Acquire), + locked: lock.is_locked(), + available: unavailable_reason.is_none(), + unavailable_reason, + } +} + +#[tauri::command(async)] +pub fn app_unlock(app: tauri::AppHandle) -> Result<(), String> { + let lock = app.state::(); + if lock.is_locked() { + authenticate("Unlock your mailbox to read and write email.")?; + lock.unlocked.store(true, Ordering::Release); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn availability() -> Result<(), String> { + use objc2_local_authentication::{LAContext, LAPolicy}; + + let context = unsafe { LAContext::new() }; + unsafe { context.canEvaluatePolicy_error(LAPolicy::DeviceOwnerAuthentication) } + .map_err(|error| error.localizedDescription().to_string()) +} + +#[cfg(target_os = "macos")] +fn authenticate(reason: &str) -> Result<(), String> { + use std::sync::mpsc; + + use block2::RcBlock; + use objc2::runtime::Bool; + use objc2_foundation::{NSError, NSString}; + use objc2_local_authentication::{LAContext, LAPolicy}; + + let context = unsafe { LAContext::new() }; + unsafe { context.canEvaluatePolicy_error(LAPolicy::DeviceOwnerAuthentication) } + .map_err(|error| error.localizedDescription().to_string())?; + let (sender, receiver) = mpsc::channel(); + let reply = RcBlock::new(move |success: Bool, error: *mut NSError| { + let result = if success.as_bool() { + Ok(()) + } else { + let message = if error.is_null() { + "Authentication was not completed.".to_string() + } else { + unsafe { &*error }.localizedDescription().to_string() + }; + Err(message) + }; + let _ = sender.send(result); + }); + unsafe { + context.evaluatePolicy_localizedReason_reply( + LAPolicy::DeviceOwnerAuthentication, + &NSString::from_str(reason), + &reply, + ); + } + receiver + .recv() + .map_err(|_| "macOS did not complete authentication.".to_string())? +} + +#[cfg(not(target_os = "macos"))] +fn availability() -> Result<(), String> { + Err("The app opening lock is currently available on macOS.".to_string()) +} + +#[cfg(not(target_os = "macos"))] +fn authenticate(_reason: &str) -> Result<(), String> { + availability() +} diff --git a/src-tauri/src/drafts.rs b/src-tauri/src/drafts.rs index 5bd6f5b..587ddc2 100644 --- a/src-tauri/src/drafts.rs +++ b/src-tauri/src/drafts.rs @@ -40,6 +40,35 @@ pub const MAX_ENCODED_BYTES: u64 = 35 * 1024 * 1024; /// arrives; this decides how often one of them leaves the machine. pub const UPLOAD_EVERY_MS: i64 = 5_000; +#[tauri::command(async)] +pub fn draft_attachment_store( + app: tauri::AppHandle, + account_id: String, + filename: String, + mime_type: String, + data_base64: String, +) -> Result { + use base64::Engine; + if data_base64.len() as u64 > MAX_ENCODED_BYTES { + return Err(format!("{filename} is too large to attach")); + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(data_base64).map_err(|e| e.to_string())?; + let db = db_of(&app)?; + db.with(&account_id, |_| Ok(()))?; + let directory = db.account_dir(&account_id).join("draft-files"); + std::fs::create_dir_all(&directory).map_err(|e| e.to_string())?; + let path = directory.join(write::fresh_id("file")); + std::fs::write(&path, &bytes).map_err(|e| format!("{filename} could not be stored: {e}"))?; + Ok(DraftAttachment { + path: Some(path.to_string_lossy().into_owned()), + attachment_id: None, + filename, + mime_type, + size: bytes.len() as u64, + }) +} + /// What sits in the `drafts` row's payload. /// /// The version is bumped on every save and is what says whether the provider is behind. A diff --git a/src-tauri/src/dto.rs b/src-tauri/src/dto.rs index 9dd5143..649e10d 100644 --- a/src-tauri/src/dto.rs +++ b/src-tauri/src/dto.rs @@ -785,6 +785,16 @@ pub struct Settings { /// because they are a decision about a person and they roam with the rest of those. pub remote_images: String, pub link_cleaning: bool, + #[serde(default)] + pub otp_autofill_enabled: bool, + #[serde(default)] + pub app_lock_enabled: bool, + #[serde(default = "on")] + pub spelling_enabled: bool, + #[serde(default)] + pub grammar_enabled: bool, + #[serde(default)] + pub writing_tools_enabled: bool, pub screener_enabled: bool, /// A reply to a thread you are in is never held. Turning this off holds it anyway. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 42aed4b..07567a0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ // front of it, the modules are the parts, and a contract type that nothing has consumed yet is a // contract type rather than dead code. pub mod accounts; +pub mod app_lock; pub mod attachments; pub mod imap; pub mod backup; @@ -24,6 +25,8 @@ pub mod mirror; pub mod notify; pub mod piles; pub mod provider; +pub mod proofing; +pub mod otp_autofill; pub mod routing; pub mod sanitize; pub mod screener; @@ -34,6 +37,7 @@ pub mod state; pub mod sync; pub mod undo; pub mod unsubscribe; +pub mod writingtools; #[cfg(desktop)] use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, SubmenuBuilder}; @@ -298,7 +302,9 @@ pub fn run() { } builder = builder.manage(google::AuthState::default()).setup(|app| { + app.manage(app_lock::AppLock::new(settings::load(app.handle())?.app_lock_enabled)); let handle = app.handle(); + tauri::async_runtime::spawn_blocking(|| { let _ = otp_autofill::clear(); }); // The mirror and the state database, one connection per account, opened lazily by the // first read. Managed here because every command that touches SQLite reaches for it. @@ -384,7 +390,15 @@ pub fn run() { } let app = builder - .invoke_handler(tauri::generate_handler![ + .invoke_handler({ + let handler: Box) -> bool + Send + Sync> = Box::new(tauri::generate_handler![ + app_lock::app_lock_status, + app_lock::app_unlock, + proofing::proof_text, + otp_autofill::otp_autofill_status, + otp_autofill::otp_autofill_enable, + writingtools::writing_tools_available, + writingtools::run_writing_tool, // Accounts and consent accounts::accounts_list, account_start, @@ -451,6 +465,7 @@ pub fn run() { contacts::contacts_suggest, // Writing drafts::draft_save, + drafts::draft_attachment_store, drafts::draft_get, drafts::draft_list, drafts::draft_import, @@ -489,7 +504,15 @@ pub fn run() { exports::keymap_path, exports::keymap_reset, packaged_by - ]) + ]); + move |invoke| { + if !app_lock::permits(&invoke) { + invoke.resolver.reject("Unlock your mailbox first."); + return true; + } + handler(invoke) + } + }) .build(context) .expect("error while building Margin Mail"); diff --git a/src-tauri/src/notify.rs b/src-tauri/src/notify.rs index 1304812..07a6700 100644 --- a/src-tauri/src/notify.rs +++ b/src-tauri/src/notify.rs @@ -450,6 +450,9 @@ pub fn announce(app: &tauri::AppHandle, account_id: &str, arrivals: &[Arrival]) /// Posts one notification, and writes down why when it could not. fn post(app: &tauri::AppHandle, text: &Text) -> Result<(), String> { + if crate::app_lock::is_locked(app) { + return Ok(()); + } let result = platform::post(app, text); if let Err(e) = &result { crate::log::note("notify", &format!("could not post \"{}\": {e}", text.body)); @@ -582,4 +585,3 @@ pub fn opened(app: &tauri::AppHandle, target: Option) { pub fn notify_take() -> Option { OPENED.lock().ok().and_then(|mut slot| slot.take()) } - diff --git a/src-tauri/src/otp_autofill.rs b/src-tauri/src/otp_autofill.rs new file mode 100644 index 0000000..2a67b1a --- /dev/null +++ b/src-tauri/src/otp_autofill.rs @@ -0,0 +1,344 @@ +use serde::Serialize; + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EmailCode { + id: String, + code: String, + domain: String, + label: String, + expires_at_ms: i64, +} + +#[derive(Serialize)] +pub struct AutoFillStatus { + available: bool, + enabled: bool, +} + +#[tauri::command(async)] +pub fn otp_autofill_status() -> AutoFillStatus { + AutoFillStatus { + available: native::available(), + enabled: native::enabled().unwrap_or(false), + } +} + +#[tauri::command] +pub async fn otp_autofill_enable(app: tauri::AppHandle) -> Result { + if !native::available() { + return Err("Email OTP AutoFill requires macOS 15 or later.".into()); + } + let (sender, receiver) = tokio::sync::oneshot::channel(); + app.run_on_main_thread(move || native::enable(sender)) + .map_err(|error| error.to_string())?; + receiver.await.map_err(|error| error.to_string())? +} + +pub fn validate_enabled() -> Result<(), String> { + if native::enabled()? { + Ok(()) + } else { + Err("Enable Margin Mail in macOS AutoFill & Passwords first.".into()) + } +} + +pub fn clear() -> Result<(), String> { + native::publish(&[], None) +} + +fn code_in(subject: &str, body: &str) -> Option { + use std::collections::HashSet; + use std::sync::LazyLock; + static CUE: LazyLock = LazyLock::new(|| { + regex::Regex::new( + r"(?i)\b(verification|security|authentication|confirmation|login|sign[ -]?in|one[ -]?time|otp|passcode|your\s+code|enter\s+(the\s+)?code)\b" + ).unwrap() + }); + static TOKEN: LazyLock = + LazyLock::new(|| regex::Regex::new(r"\b[A-Z0-9]{4,8}\b").unwrap()); + static DURATION: LazyLock = + LazyLock::new(|| regex::Regex::new(r"(?i)^\s*(seconds?|minutes?|hours?|days?)\b").unwrap()); + let (body, _) = crate::sanitize::quoted::split_text(body); + let text = format!("{subject}\n{body}"); + let mut codes = HashSet::new(); + for token in TOKEN.find_iter(&text) { + let value = token.as_str(); + if !value.bytes().any(|byte| byte.is_ascii_digit()) + || (!value.bytes().all(|byte| byte.is_ascii_digit()) && value.len() < 6) + { + continue; + } + let before: String = text[..token.start()] + .chars() + .rev() + .take(90) + .collect::() + .chars() + .rev() + .collect(); + let after: String = text[token.end()..].chars().take(60).collect(); + if CUE.is_match(&format!("{before}{value}{after}")) && !DURATION.is_match(&after) { + codes.insert(value.to_string()); + } + } + if codes.len() == 1 { + codes.into_iter().next() + } else { + None + } +} + +pub async fn refresh(app: &tauri::AppHandle) -> Result<(), String> { + use tauri::Manager; + if !crate::settings::load(app)?.otp_autofill_enabled || crate::app_lock::is_locked(app) { + return Ok(()); + } + let db = app.state::(); + let now = crate::mirror::write::now_ms(); + let mut codes = Vec::new(); + for account_id in crate::sync::attached() { + let rows = db.with(&account_id, |conn| { + let mut query = conn + .prepare( + "SELECT m.id, m.subject, m.from_address, m.from_name, m.date_ms, b.text + FROM messages m LEFT JOIN bodies b ON b.message_id = m.id + JOIN threads t ON t.provider_thread_id = m.provider_thread_id + WHERE m.date_ms > ?1 AND m.date_ms <= ?2 AND m.seen = 0 AND m.sent = 0 + AND m.draft = 0 AND m.hydrated = 1 AND t.spam = 0 AND t.trashed = 0 + ORDER BY m.date_ms DESC LIMIT 20", + ) + .map_err(|error| error.to_string())?; + let rows = query + .query_map(rusqlite::params![now - 180_000, now], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, Option>(5)?, + )) + }) + .map_err(|error| error.to_string())?; + rows.collect::, _>>() + .map_err(|error| error.to_string()) + })?; + for (id, subject, sender, name, date, stored_body) in rows { + let body = if let Some(body) = stored_body { + body + } else { + let Some(remote) = crate::sync::remote_for(&account_id) else { + continue; + }; + let raw = match remote.fetch_body(&id).await { + Ok(raw) => raw, + Err(_) => continue, + }; + db.with(&account_id, |conn| { + let options = crate::sync::hydrate::render_options(conn)?; + crate::mirror::write::store_body(conn, &id, &raw, &options)?; + conn.query_row( + "SELECT text FROM bodies WHERE message_id = ?1", + [&id], + |row| row.get::<_, String>(0), + ) + .map_err(|error| error.to_string()) + })? + }; + let Some(code) = code_in(&subject, &body) else { + continue; + }; + let Some((_, domain)) = sender.rsplit_once('@') else { + continue; + }; + let domain = domain.to_lowercase(); + if !domain.contains('.') + || domain.chars().any(|character| { + !(character.is_ascii_alphanumeric() || character == '.' || character == '-') + }) + { + continue; + } + codes.push(EmailCode { + id: format!("{account_id}:{id}"), + code, + domain, + label: name + .filter(|name| !name.trim().is_empty()) + .unwrap_or(sender), + expires_at_ms: date + 180_000, + }); + } + } + if !crate::settings::load(app)?.otp_autofill_enabled || crate::app_lock::is_locked(app) { + return clear(); + } + codes.retain(|code| code.expires_at_ms > crate::mirror::write::now_ms()); + native::publish(&codes, Some(app)) +} + +#[cfg(target_os = "macos")] +mod native { + use super::EmailCode; + use block2::RcBlock; + use objc2::{ + msg_send, + rc::{Allocated, Retained}, + runtime::{AnyClass, Bool}, + }; + use objc2_foundation::{NSArray, NSError, NSFileManager, NSObject, NSString}; + use std::sync::{mpsc, Mutex}; + + #[link(name = "AuthenticationServices", kind = "framework")] + unsafe extern "C" {} + static PUBLISH: Mutex<()> = Mutex::new(()); + + pub fn available() -> bool { + let bundled = std::env::current_exe() + .ok() + .and_then(|path| { + path.parent()?.parent().map(|path| { + path.join("PlugIns/MarginMailAutoFill.appex/Contents/embedded.provisionprofile") + .is_file() + }) + }) + .unwrap_or(false); + bundled + && AnyClass::get(c"ASOneTimeCodeCredentialIdentity").is_some() + && NSFileManager::defaultManager() + .containerURLForSecurityApplicationGroupIdentifier(&NSString::from_str( + "TQV87WLXK3.studio.margin.mail", + )) + .is_some() + } + + fn store() -> Result, String> { + let class = + AnyClass::get(c"ASCredentialIdentityStore").ok_or("macOS AutoFill is unavailable")?; + Ok(unsafe { msg_send![class, sharedStore] }) + } + + pub fn enabled() -> Result { + if !available() { + return Ok(false); + } + let (sender, receiver) = mpsc::channel(); + let reply = RcBlock::new(move |state: *mut NSObject| { + let enabled: Bool = unsafe { msg_send![state, isEnabled] }; + let _ = sender.send(enabled.as_bool()); + }); + unsafe { + let _: () = + msg_send![&*store()?, getCredentialIdentityStoreStateWithCompletion: &*reply]; + } + receiver.recv().map_err(|error| error.to_string()) + } + + pub fn enable(sender: tokio::sync::oneshot::Sender>) { + let Some(class) = AnyClass::get(c"ASSettingsHelper") else { + let _ = sender.send(Err("macOS AutoFill is unavailable".into())); + return; + }; + let sender = std::sync::Mutex::new(Some(sender)); + let reply = RcBlock::new(move |enabled: Bool| { + if let Some(sender) = sender.lock().ok().and_then(|mut sender| sender.take()) { + let _ = sender.send(Ok(enabled.as_bool())); + } + }); + unsafe { + let _: () = msg_send![class, requestToTurnOnCredentialProviderExtensionWithCompletionHandler: &*reply]; + } + } + + pub fn publish(codes: &[EmailCode], app: Option<&tauri::AppHandle>) -> Result<(), String> { + if !available() { + return Ok(()); + } + let _guard = PUBLISH.lock().map_err(|error| error.to_string())?; + let codes = if let Some(app) = app { + if !crate::settings::load(app)?.otp_autofill_enabled || crate::app_lock::is_locked(app) + { + &[] + } else { + codes + } + } else { + codes + }; + let directory = NSFileManager::defaultManager() + .containerURLForSecurityApplicationGroupIdentifier(&NSString::from_str( + "TQV87WLXK3.studio.margin.mail", + )) + .and_then(|url| url.path()) + .ok_or("Margin Mail AutoFill signing is not configured")?; + let path = std::path::PathBuf::from(directory.to_string()); + std::fs::create_dir_all(&path).map_err(|error| error.to_string())?; + let temporary = path.join("email-otp.pending"); + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&temporary) + .map_err(|error| error.to_string())?; + file.write_all(&serde_json::to_vec(codes).map_err(|error| error.to_string())?) + .map_err(|error| error.to_string())?; + std::fs::rename(&temporary, path.join("email-otp.json")) + .map_err(|error| error.to_string())?; + if !enabled()? { + return Ok(()); + } + let service_class = AnyClass::get(c"ASCredentialServiceIdentifier") + .ok_or("macOS AutoFill is unavailable")?; + let identity_class = AnyClass::get(c"ASOneTimeCodeCredentialIdentity") + .ok_or("Email OTP AutoFill requires macOS 15")?; + let mut identities: Vec> = Vec::new(); + for code in codes { + let allocated: Allocated = unsafe { msg_send![service_class, alloc] }; + let service: Retained = unsafe { + msg_send![allocated, initWithIdentifier: &*NSString::from_str(&code.domain), type: 0isize] + }; + let allocated: Allocated = unsafe { msg_send![identity_class, alloc] }; + let identity = unsafe { + msg_send![allocated, initWithServiceIdentifier: &*service, label: &*NSString::from_str(&code.label), recordIdentifier: &*NSString::from_str(&code.id)] + }; + identities.push(identity); + } + let entries = NSArray::from_retained_slice(&identities); + let (sender, receiver) = mpsc::channel(); + let reply = RcBlock::new(move |success: Bool, error: *mut NSError| { + let result = if success.as_bool() { + Ok(()) + } else if error.is_null() { + Err("macOS could not update OTP suggestions".into()) + } else { + Err(unsafe { &*error }.localizedDescription().to_string()) + }; + let _ = sender.send(result); + }); + unsafe { + let _: () = msg_send![&*store()?, replaceCredentialIdentityEntries: &*entries, completion: &*reply]; + } + receiver.recv().map_err(|error| error.to_string())? + } +} + +#[cfg(not(target_os = "macos"))] +mod native { + use super::EmailCode; + pub fn available() -> bool { + false + } + pub fn enabled() -> Result { + Ok(false) + } + pub fn publish(_: &[EmailCode], _: Option<&tauri::AppHandle>) -> Result<(), String> { + Ok(()) + } + pub fn enable(sender: tokio::sync::oneshot::Sender>) { + let _ = sender.send(Err("Email OTP AutoFill requires macOS".into())); + } +} diff --git a/src-tauri/src/proofing.rs b/src-tauri/src/proofing.rs new file mode 100644 index 0000000..f651ddb --- /dev/null +++ b/src-tauri/src/proofing.rs @@ -0,0 +1,90 @@ +use serde::Serialize; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProofIssue { + start: usize, + end: usize, + kind: &'static str, + message: String, + suggestions: Vec, +} + +#[tauri::command] +pub async fn proof_text(text: String, spelling: bool, grammar: bool) -> Result, String> { + #[cfg(target_os = "macos")] + { + tauri::async_runtime::spawn_blocking(move || check(&text, spelling, grammar)) + .await + .map_err(|error| error.to_string()) + } + #[cfg(not(target_os = "macos"))] + { + let _ = (text, spelling, grammar); + Ok(Vec::new()) + } +} + +#[cfg(target_os = "macos")] +fn check(text: &str, spelling: bool, grammar: bool) -> Vec { + use objc2::rc::autoreleasepool; + use objc2_app_kit::NSSpellChecker; + use objc2_foundation::{NSArray, NSRange, NSString, NSTextCheckingType, NSValue}; + + autoreleasepool(|_| { + let checker = NSSpellChecker::sharedSpellChecker(); + let string = NSString::from_str(text); + let mut types = NSTextCheckingType::empty(); + if spelling { + types |= NSTextCheckingType::Spelling; + } + if grammar { + types |= NSTextCheckingType::Grammar; + } + let results = unsafe { + checker.checkString_range_types_options_inSpellDocumentWithTag_orthography_wordCount( + &string, + NSRange { location: 0, length: string.length() }, + types.bits(), + None, + 0, + None, + std::ptr::null_mut(), + ) + }; + let mut issues = Vec::new(); + for result in results.iter() { + let range = result.range(); + if result.resultType() == NSTextCheckingType::Spelling { + let suggestions = checker + .guessesForWordRange_inString_language_inSpellDocumentWithTag(range, &string, None, 0) + .map(|guesses| guesses.iter().take(5).map(|guess| guess.to_string()).collect()) + .unwrap_or_default(); + issues.push(ProofIssue { + start: range.location, + end: range.location + range.length, + kind: "spelling", + message: "Check spelling".into(), + suggestions, + }); + } else if result.resultType() == NSTextCheckingType::Grammar { + if let Some(details) = result.grammarDetails() { + for detail in details.iter() { + let relative = detail.objectForKey(&NSString::from_str("NSGrammarRange")) + .and_then(|value| value.downcast_ref::().map(|value| unsafe { value.rangeValue() })); + let start = range.location + relative.map_or(0, |value| value.location); + let length = relative.map_or(range.length, |value| value.length); + let message = detail.objectForKey(&NSString::from_str("NSGrammarUserDescription")) + .and_then(|value| value.downcast_ref::().map(|value| value.to_string())) + .unwrap_or_else(|| "Check grammar".into()); + let suggestions = detail.objectForKey(&NSString::from_str("NSGrammarCorrections")) + .and_then(|value| value.downcast_ref::().map(|values| values.iter().filter_map(|value| value.downcast_ref::().map(|value| value.to_string())).take(5).collect())) + .unwrap_or_default(); + issues.push(ProofIssue { start, end: start + length, kind: "grammar", message, suggestions }); + } + } + } + } + issues + }) +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 82ee981..fafbe5c 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -48,6 +48,11 @@ pub fn defaults() -> Settings { remote_images: "ask".to_string(), link_cleaning: true, + otp_autofill_enabled: false, + app_lock_enabled: false, + spelling_enabled: true, + grammar_enabled: false, + writing_tools_enabled: false, screener_enabled: true, hold_replies: false, @@ -152,7 +157,15 @@ pub fn settings_set(app: tauri::AppHandle, patch: Value) -> Result().configure(after.app_lock_enabled); for (account_id, days) in window_changes(&before, &after) { crate::sync::window_set(&app, &account_id, days)?; diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index 989a634..dc47d3d 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -294,6 +294,17 @@ impl Engine { remote: &dyn Remote, sink: &dyn Sink, foreground: bool, + ) -> SyncStatus { + self.run_scheduled_pass(store, remote, sink, foreground, true).await + } + + pub async fn run_scheduled_pass( + &self, + store: &S, + remote: &dyn Remote, + sink: &dyn Sink, + foreground: bool, + maintenance: bool, ) -> SyncStatus { if self.running.swap(true, Ordering::AcqRel) { return self.status(); @@ -337,8 +348,10 @@ impl Engine { // the seed is waiting on and asking beforehand would answer "not yet" on the very pass // that made it ready. self.seed_when_ready(store, sink); - self.housekeeping(store, remote, sink, &mut status, foreground) - .await; + if maintenance { + self.housekeeping(store, remote, sink, &mut status, foreground) + .await; + } } status.pending_writes = store.with(write::pending_writes).unwrap_or(0); diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index 5d99b18..3011bf5 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -47,6 +47,7 @@ pub struct Outcome { pub const POLL_FOREGROUND_SECS: u64 = 12; pub const POLL_BACKGROUND_SECS: u64 = 60; +const RECEIVE_POLL_SECS: u64 = 5; /// A cold start should show fresh mail rather than wait out a poll interval. const FIRST_PASS_SECS: u64 = 2; @@ -373,15 +374,23 @@ fn db_of(app: &tauri::AppHandle) -> Result, String> { /// Starts the poll loop. The integrator calls this from `setup` once the database is managed. pub fn setup(app: tauri::AppHandle) { tauri::async_runtime::spawn(async move { - let mut delay = Duration::from_secs(FIRST_PASS_SECS); + let start = tokio::time::Instant::now() + Duration::from_secs(FIRST_PASS_SECS); + let mut polls = tokio::time::interval_at(start, Duration::from_secs(RECEIVE_POLL_SECS)); + polls.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut maintained: Option = None; loop { - tokio::time::sleep(delay).await; - tick(&app).await; - delay = Duration::from_secs(if focused(&app) { + polls.tick().await; + let foreground = focused(&app); + let cadence = Duration::from_secs(if foreground { POLL_FOREGROUND_SECS } else { POLL_BACKGROUND_SECS }); + let maintenance = maintained.map(|last| last.elapsed() >= cadence).unwrap_or(true); + if maintenance { + maintained = Some(tokio::time::Instant::now()); + } + tick(&app, foreground, maintenance).await; } }); } @@ -392,26 +401,34 @@ fn focused(app: &tauri::AppHandle) -> bool { .any(|window| window.is_focused().unwrap_or(false)) } -async fn tick(app: &tauri::AppHandle) { +async fn tick(app: &tauri::AppHandle, foreground: bool, maintenance: bool) { let Ok(db) = db_of(app) else { return }; let sink = AppSink { app: app.clone() }; - for account_id in attached() { + let db = db.inner(); + let sink = &sink; + let passes = attached().into_iter().map(|account_id| async move { let Some(remote) = remote_for(&account_id) else { - continue; + return; }; let engine = engine_for(&account_id); let store = Scoped { - db: db.inner(), + db, account_id: &account_id, }; engine - .run_pass(&store, remote.as_ref(), &sink, focused(app)) + .run_scheduled_pass(&store, remote.as_ref(), sink, foreground, maintenance) .await; // A draft written just before a quit has a local row and no provider copy yet. The pass is // where it catches up, so a draft roams the way Gmail drafts always have rather than // waiting for the composer to be opened again. - let _ = crate::drafts::upload_pending(app, &account_id).await; + if maintenance { + let _ = crate::drafts::upload_pending(app, &account_id).await; + } + }); + futures::future::join_all(passes).await; + if let Err(error) = crate::otp_autofill::refresh(app).await { + crate::log::note("autofill", &error); } } diff --git a/src-tauri/src/writingtools.rs b/src-tauri/src/writingtools.rs new file mode 100644 index 0000000..85f139f --- /dev/null +++ b/src-tauri/src/writingtools.rs @@ -0,0 +1,77 @@ +#[cfg(target_os = "macos")] +mod mac { + use objc2::rc::Retained; + use objc2::runtime::AnyClass; + use objc2::{msg_send, sel, MainThreadMarker}; + use objc2_app_kit::{NSApplication, NSMenu}; + + pub fn available() -> bool { + let Some(class) = AnyClass::get(c"NSWritingToolsCoordinator") else { return false }; + unsafe { + let responds: bool = msg_send![class, respondsToSelector: sel!(isWritingToolsAvailable)]; + responds && msg_send![class, isWritingToolsAvailable] + } + } + + fn writing_menu(mtm: MainThreadMarker) -> Option> { + let main = NSApplication::sharedApplication(mtm).mainMenu()?; + for item in main.itemArray().iter() { + let Some(edit) = item.submenu() else { continue }; + if edit.title().to_string() != "Edit" && item.title().to_string() != "Edit" { continue; } + for child in edit.itemArray().iter() { + if child.title().to_string() == "Writing Tools" { return child.submenu(); } + } + } + None + } + + pub fn perform(tool: &str) -> Result<(), String> { + let mtm = MainThreadMarker::new().ok_or("Writing Tools must run on the main thread")?; + if !available() { + return Err("Apple Writing Tools are unavailable. Check Apple Intelligence in System Settings.".into()); + } + let menu = writing_menu(mtm).ok_or("Apple Writing Tools are unavailable in the app’s Edit menu")?; + menu.update(); + for (index, item) in menu.itemArray().iter().enumerate() { + if item.title().to_string() != tool { continue; } + if !item.isEnabled() { + return Err("Select text in your message before using Apple Writing Tools.".into()); + } + let action = item.action().ok_or("The Writing Tools action is unavailable")?; + let application = NSApplication::sharedApplication(mtm); + if unsafe { application.targetForAction_to_from(action, item.target().as_deref(), Some(&item)) }.is_none() { + return Err("Apple Writing Tools cannot edit the selected text.".into()); + } + menu.performActionForItemAtIndex(index as isize); + return Ok(()); + } + Err(format!("Apple {tool} is unavailable in the app’s Edit menu")) + } +} + +#[tauri::command] +pub fn writing_tools_available() -> bool { + #[cfg(target_os = "macos")] + { mac::available() } + #[cfg(not(target_os = "macos"))] + { false } +} + +#[tauri::command] +pub async fn run_writing_tool(app: tauri::AppHandle, tool: String) -> Result<(), String> { + if !matches!(tool.as_str(), "Proofread" | "Rewrite") { + return Err("Unknown Writing Tools action".into()); + } + if !crate::settings::load(&app)?.writing_tools_enabled { + return Err("Enable Apple Writing Tools in Writing settings first".into()); + } + #[cfg(target_os = "macos")] + { + let (sender, receiver) = tokio::sync::oneshot::channel(); + app.run_on_main_thread(move || { let _ = sender.send(mac::perform(&tool)); }) + .map_err(|error| error.to_string())?; + receiver.await.map_err(|error| error.to_string())? + } + #[cfg(not(target_os = "macos"))] + { Err("Apple Writing Tools require macOS".into()) } +} diff --git a/src/App.tsx b/src/App.tsx index 1d73e5e..1c2e832 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { Header } from "./screens/Header"; import { ListColumn } from "./screens/ListColumn"; import { Clips } from "./screens/Clips"; import { Compose } from "./screens/Compose"; +import { AppLockGate } from "./screens/AppLock"; import { ContactCards } from "./screens/ContactCards"; import { Contacts } from "./screens/Contacts"; import { Feed } from "./screens/Feed"; @@ -447,7 +448,7 @@ function App() { ); } - return ; + return ; } export default App; diff --git a/src/api/appLock.ts b/src/api/appLock.ts new file mode 100644 index 0000000..f3a5daa --- /dev/null +++ b/src/api/appLock.ts @@ -0,0 +1,15 @@ +import { call, isTauri } from "../ipc"; + +export interface AppLockStatus { + enabled: boolean; + locked: boolean; + available: boolean; + unavailableReason: string | null; +} + +export const appLockStatus = (): Promise => + isTauri + ? call("app_lock_status") + : Promise.resolve({ enabled: false, locked: false, available: false, unavailableReason: null }); + +export const appUnlock = () => call("app_unlock"); diff --git a/src/api/otpAutofill.ts b/src/api/otpAutofill.ts new file mode 100644 index 0000000..c554f92 --- /dev/null +++ b/src/api/otpAutofill.ts @@ -0,0 +1,14 @@ +import { call, isTauri } from "../ipc"; + +export interface OtpAutofillStatus { + available: boolean; + enabled: boolean; +} + +export const otpAutofillStatus = (): Promise => + isTauri + ? call("otp_autofill_status") + : Promise.resolve({ available: false, enabled: false }); + +export const otpAutofillEnable = (): Promise => + isTauri ? call("otp_autofill_enable") : Promise.resolve(false); diff --git a/src/api/proofing.ts b/src/api/proofing.ts new file mode 100644 index 0000000..b64ea1b --- /dev/null +++ b/src/api/proofing.ts @@ -0,0 +1,12 @@ +import { call } from "../ipc"; + +export interface ProofIssue { + start: number; + end: number; + kind: "spelling" | "grammar"; + message: string; + suggestions: string[]; +} + +export const proofText = (text: string, spelling: boolean, grammar: boolean) => + call("proof_text", { text, spelling, grammar }); diff --git a/src/api/write.ts b/src/api/write.ts index c5bd6e6..d92cc07 100644 --- a/src/api/write.ts +++ b/src/api/write.ts @@ -1,12 +1,16 @@ import { call, type Draft, + type DraftAttachment, type DraftSaved, type InviteResponse, type Outgoing, type Undo, } from "../ipc"; +export const draftAttachmentStore = (accountId: string, file: DraftAttachment, dataBase64: string) => + call("draft_attachment_store", { accountId, filename: file.filename, mimeType: file.mimeType, dataBase64 }); + /** Saves locally on every keystroke's debounce and to the provider every few seconds. */ export const draftSave = (draft: Draft) => call("draft_save", { draft }); diff --git a/src/api/writingtools.ts b/src/api/writingtools.ts new file mode 100644 index 0000000..3d8ed39 --- /dev/null +++ b/src/api/writingtools.ts @@ -0,0 +1,7 @@ +import { call, isMacDesktop } from "../ipc"; + +export const writingToolsAvailable = () => + isMacDesktop ? call("writing_tools_available") : Promise.resolve(false); + +export const runWritingTool = (tool: "Proofread" | "Rewrite") => + call("run_writing_tool", { tool }); diff --git a/src/dev/fixture.ts b/src/dev/fixture.ts index cc8e63b..d4fc407 100644 --- a/src/dev/fixture.ts +++ b/src/dev/fixture.ts @@ -1268,6 +1268,11 @@ export const devSettings: Settings = { remoteImages: "ask", linkCleaning: true, + otpAutofillEnabled: false, + appLockEnabled: false, + spellingEnabled: true, + grammarEnabled: false, + writingToolsEnabled: false, screenerEnabled: true, holdReplies: false, diff --git a/src/dev/mockIpc.ts b/src/dev/mockIpc.ts index cd42daf..3dcd26e 100644 --- a/src/dev/mockIpc.ts +++ b/src/dev/mockIpc.ts @@ -287,15 +287,16 @@ interface UndoEntry { token: string; label: string; revert: () => void; + outgoingId?: string; } const undoStack: UndoEntry[] = []; let undoCount = 0; -function undoable(label: string, revert: () => void, undoMs = 0): Undo { +function undoable(label: string, revert: () => void, undoMs = 0, outgoingId?: string): Undo { undoCount += 1; const token = `undo-${undoCount}`; - undoStack.push({ token, label, revert }); + undoStack.push({ token, label, revert, outgoingId }); // Bounded in Rust for the same reason: a stack that grows for a session is a leak. if (undoStack.length > 25) undoStack.shift(); return { token, label, undoMs }; @@ -1451,6 +1452,15 @@ export async function mockCall(command: string, args?: Record("accountId"); + return done( + empty ? [] : drafts + .filter((draft) => !accountId || draft.accountId === accountId) + .map((draft) => structuredClone(draft)), + ); + } + case "draft_save": { const draft = arg("draft"); const id = draft.id ?? `draft-${drafts.length + 1}`; @@ -1462,7 +1472,7 @@ export async function mockCall(command: string, args?: Record(command: string, args?: Record(command: string, args?: Record saved.id === draft.id); + const saved = savedIndex >= 0 ? drafts.splice(savedIndex, 1)[0] : undefined; + changed("outbox drafts threads"); const to = draft.to[0]?.name ?? draft.to[0]?.address ?? "nobody"; return done( undoable( @@ -1518,18 +1530,21 @@ export async function mockCall(command: string, args?: Record { const index = outbox.findIndex((item) => item.id === id); if (index >= 0) outbox.splice(index, 1); + if (saved) drafts.push(saved); if (thread) { thread.sending = false; thread.hasDraft = true; } }, settings.undoDelaySecs * 1_000, + id, ), ); } case "send_now": { - const id = arg("outgoingId"); + const outgoingId = arg("outgoingId"); + const id = undoStack.find((entry) => entry.token === outgoingId)?.outgoingId ?? outgoingId; // Rust pushes the message to the provider before it answers, and the line has a busy state // for that wait: the same beat as the bodies, so it can be looked at. if (flagged("marginmail-dev-pending")) await beat(900); diff --git a/src/ipc.ts b/src/ipc.ts index b297225..497e300 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -622,6 +622,11 @@ export interface Settings { /** The per sender allowances are not here: they live on the contact and roam with it. */ remoteImages: "never" | "ask" | "always"; linkCleaning: boolean; + otpAutofillEnabled: boolean; + appLockEnabled: boolean; + spellingEnabled: boolean; + grammarEnabled: boolean; + writingToolsEnabled: boolean; screenerEnabled: boolean; holdReplies: boolean; diff --git a/src/screens/AppLock.tsx b/src/screens/AppLock.tsx new file mode 100644 index 0000000..6871e23 --- /dev/null +++ b/src/screens/AppLock.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { appLockStatus, appUnlock, type AppLockStatus } from "../api/appLock"; +import { Button } from "../ui/Button"; +import "./app-lock.css"; + +export function AppLockGate({ children }: { children: ReactNode }) { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const readStatus = async () => { + setBusy(true); + setError(null); + try { + setStatus(await appLockStatus()); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }; + + useEffect(() => { + void readStatus(); + }, []); + + const unlock = async () => { + setBusy(true); + setError(null); + try { + await appUnlock(); + setStatus(await appLockStatus()); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }; + + if (status && !status.locked) return children; + if (!status && !error) return
; + + return ( +
+
+

Mailbox locked

+

+ Use Touch ID or your Mac login password to open your mailbox. +

+ {error || status?.unavailableReason ? ( +

{error ?? status?.unavailableReason}

+ ) : null} + +
+
+ ); +} diff --git a/src/screens/Compose.tsx b/src/screens/Compose.tsx index c8d9518..7fc89e7 100644 --- a/src/screens/Compose.tsx +++ b/src/screens/Compose.tsx @@ -15,9 +15,11 @@ import { useEscapeLayer } from "../escape"; import { registerCommands } from "../keys/commands"; import { useKeyContext } from "../keys/keymap"; import { contactsSuggest } from "../api/contacts"; +import { draftAttachmentStore } from "../api/write"; +import { notify } from "../store/useToast"; import { isTauri, type Draft, type DraftAttachment, type Person } from "../ipc"; import { useAccounts } from "../store/useAccounts"; -import { useCompose, type Composer, type ComposerAt } from "../store/useCompose"; +import { storeAttachments, useCompose, type Composer, type ComposerAt } from "../store/useCompose"; import { useMail } from "../store/useMail"; import { useSettings } from "../store/useSettings"; import { Editor } from "./Editor"; @@ -139,35 +141,63 @@ function defaultReminder(): number { // Files // ------------------------------------------------------------------------------------------- -/** - * The webview's own picker, which is the only one this app has: there is no dialog plugin in - * `src-tauri`, and adding one is a decision for whoever owns that crate. - * - * A `File` from a webview has a name, a size and a type and no path, and `DraftAttachment` carries - * a path or a mirror attachment id and nothing else. So the name goes in the path field, which is - * enough for the fixture and for the composer's own arithmetic and is not enough for Rust to read - * the bytes. Dragging a file onto a Tauri window is the route that carries a real path. - */ export function pickFiles(at: ComposerAt): void { const input = document.createElement("input"); input.type = "file"; input.multiple = true; input.style.display = "none"; input.addEventListener("change", () => { - useCompose.getState().attach(at, [...(input.files ?? [])].map(asAttachment)); + void attachFiles(at, [...(input.files ?? [])]); input.remove(); }); + input.addEventListener("cancel", () => input.remove()); document.body.append(input); input.click(); } export const asAttachment = (file: File): DraftAttachment => ({ - path: file.name, filename: file.name, mimeType: file.type || "application/octet-stream", size: file.size, }); +function attachFiles(at: ComposerAt, files: File[]): Promise { + return storeAttachments(at, persistFiles(at, files)).catch((error) => { + notify(`Could not attach files: ${error}`); + }); +} + +async function persistFiles(at: ComposerAt, files: File[]): Promise { + const composer = at === "card" ? useCompose.getState().card : useCompose.getState().reply; + if (!composer || files.length === 0) return; + const existingSize = Math.max(composer.encodedSize, + (composer.draft.attachments ?? []).reduce((size, file) => size + file.size * 4 / 3, 0)); + if (existingSize + files.reduce((size, file) => size + file.size * 4 / 3, 0) > 35 * 1024 * 1024) { + throw new Error("These files would put this message over the 35 MB the provider takes"); + } + const draftId = composer.draft.id || crypto.randomUUID(); + if (!composer.draft.id) useCompose.getState().edit(at, { id: draftId }); + const attachments: DraftAttachment[] = []; + for (const file of files) { + const descriptor = asAttachment(file); + if (!isTauri) { + attachments.push(descriptor); + continue; + } + const data = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result).split(",")[1]); + reader.onerror = () => reject(reader.error ?? new Error(`${file.name} could not be read`)); + reader.readAsDataURL(file); + }); + attachments.push(await draftAttachmentStore(composer.draft.accountId, descriptor, data)); + } + const current = at === "card" ? useCompose.getState().card : useCompose.getState().reply; + if (current?.draft.id === draftId) { + useCompose.getState().attach(at, attachments); + } +} + // ------------------------------------------------------------------------------------------- // The card // ------------------------------------------------------------------------------------------- @@ -232,6 +262,7 @@ function ComposeCard({ composer, expanded, onClose, onExpand }: ComposeCardProps const draft = composer.draft; const edit = useCompose((s) => s.edit); const setShowCc = useCompose((s) => s.setShowCc); + const signature = useSettings((s) => s.settings?.accounts.find((account) => account.accountId === draft.accountId)?.signature); return (
edit("card", { bodyHtml })} /> @@ -336,19 +368,14 @@ export function dropped(e: DragEvent, at: ComposerAt): void { const files = [...(e.dataTransfer.files ?? [])]; if (files.length === 0) return; e.preventDefault(); - useCompose.getState().attach(at, files.map(asAttachment)); + void attachFiles(at, files); } -/** - * A pasted image becomes an attachment rather than an inline part, because `DraftAttachment` has a - * path or a mirror attachment id and no content id and no inline flag. That is the contract rather - * than an oversight to route around here: an inline image needs a `cid:` on both sides. - */ export function pasted(e: ClipboardEvent, at: ComposerAt): void { const files = [...(e.clipboardData?.files ?? [])]; if (files.length === 0) return; e.preventDefault(); - useCompose.getState().attach(at, files.map(asAttachment)); + void attachFiles(at, files); } interface FromFieldProps { @@ -727,6 +754,7 @@ export function ReplyBox({ to, composer }: ReplyBoxProps) { const [closing, setClosing] = useState(false); const editor = useRef(null); const draft = composer.draft; + const signature = useSettings((s) => s.settings?.accounts.find((account) => account.accountId === draft.accountId)?.signature); // Escape leaves the editor and keeps the draft, which is docs/keyboard.md's own wording. The // second Escape is not this box's: it belongs to whatever is under it. @@ -810,6 +838,7 @@ export function ReplyBox({ to, composer }: ReplyBoxProps) { html={draft.bodyHtml} label="Reply" placeholder="Write a reply" + signature={signature} autoFocus={!forwarding} onChange={(bodyHtml) => edit("reply", { bodyHtml })} onReady={(instance) => { diff --git a/src/screens/Editor.tsx b/src/screens/Editor.tsx index cd60347..ebd18d2 100644 --- a/src/screens/Editor.tsx +++ b/src/screens/Editor.tsx @@ -1,93 +1,271 @@ -import { useEffect, useRef } from "react"; -import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { EditorContent, useEditor, useEditorState, type Editor as TiptapEditor } from "@tiptap/react"; +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; import StarterKit from "@tiptap/starter-kit"; import { hasText } from "../store/useCompose"; +import { useSettings } from "../store/useSettings"; +import { isMacDesktop } from "../ipc"; +import { proofText, type ProofIssue as NativeProofIssue } from "../api/proofing"; +import { Icon, icons } from "../ui"; +import { useEscapeLayer } from "../escape"; +import { signatureHtml } from "../signature"; +import { writingToolsAvailable, runWritingTool } from "../api/writingtools"; import "./editor.css"; -/** - * The body of a message, which is TipTap and StarterKit and nothing else. - * - * Paragraphs, bold, italic, links, lists, quotes and code, built the way margin builds its editor - * in `src/editor/extensions.ts`: one configured StarterKit rather than a list of extensions - * assembled by hand. What is deliberately absent is colour and type. A mail client that lets you - * choose a typeface is a mail client that sends mail nobody can read, and the faces in settings are - * the reader's choice rather than the writer's. - * - * There is no toolbar either. Every mark this editor can make has a key in docs/keyboard.md, the - * keys are TipTap's own, and `src/keys/bindings.ts` declares them with a null command so the - * dispatcher sees the frame and stands out of the way. `Cmd+K` is the one real collision in the - * app, and inside here the link wins because the palette is one Escape away and a link is not. - * - * It writes HTML. Rust inlines the stylesheet and builds the plain text alternative on the way out, - * which is why nothing here has an opinion about what the recipient's client can render. - */ - interface EditorProps { html: string; onChange: (html: string) => void; placeholder: string; label: string; - /** - * Takes the caret when it appears, which is what `r` is for. - * - * At the start of the document rather than the end, because a draft opens with a signature under - * it and nobody writes underneath their own name. - */ autoFocus?: boolean; - /** Handed the instance so the box around it can put the caret back. */ onReady?: (editor: TiptapEditor | null) => void; + signature?: string; } -const EXTENSIONS = [ - StarterKit.configure({ - // Mail has no headings and no rules. The subject is the heading and the hairline between - // messages is the pane's, so both would be a mark the reader meets somewhere it means nothing. - heading: false, - horizontalRule: false, - link: { openOnClick: false, autolink: true, defaultProtocol: "https" }, - }), -]; +interface ProofIssue extends NativeProofIssue { + from: number; + to: number; + word: string; +} -export function Editor({ html, onChange, placeholder, label, autoFocus, onReady }: EditorProps) { +const proofKey = new PluginKey("mail-proofing"); +const Proofing = Extension.create({ + name: "mailProofing", + addProseMirrorPlugins() { + return [new Plugin({ + key: proofKey, + state: { + init: () => DecorationSet.empty, + apply: (transaction, decorations) => { + const issues = transaction.getMeta(proofKey) as ProofIssue[] | undefined; + if (issues) return DecorationSet.create(transaction.doc, issues.map((issue) => + Decoration.inline(issue.from, issue.to, { class: `editor-proof-${issue.kind}` }))); + return transaction.docChanged ? DecorationSet.empty : decorations; + }, + }, + props: { decorations: (state) => proofKey.getState(state) }, + })]; + }, +}); + +const EXTENSIONS = [StarterKit.configure({ + heading: false, + horizontalRule: false, + link: { openOnClick: false, autolink: true, defaultProtocol: "https" }, +}), Proofing]; + +export function Editor({ html, onChange, placeholder, label, autoFocus, onReady, signature }: EditorProps) { const emitted = useRef(html); const ready = useRef(onReady); ready.current = onReady; + const spelling = useSettings((state) => state.settings?.spellingEnabled ?? true); + const grammar = useSettings((state) => state.settings?.grammarEnabled ?? false); + const writingTools = useSettings((state) => state.settings?.writingToolsEnabled ?? false); + const [appleToolsAvailable, setAppleToolsAvailable] = useState(false); + const [writingToolsError, setWritingToolsError] = useState(""); + const [linkOpen, setLinkOpen] = useState(false); + const [linkValue, setLinkValue] = useState(""); + const [linkError, setLinkError] = useState(""); + const [issues, setIssues] = useState([]); + const [proofOpen, setProofOpen] = useState(false); + const [proofError, setProofError] = useState(""); const editor = useEditor({ extensions: EXTENSIONS, content: html, autofocus: autoFocus ? "start" : false, - editorProps: { - attributes: { class: "editor-body", "aria-label": label }, - }, + editorProps: { attributes: { class: "editor-body", "aria-label": label, spellcheck: String(!isMacDesktop && spelling) } }, onUpdate: ({ editor }) => { emitted.current = editor.getHTML(); onChange(emitted.current); }, }); + useEditorState({ editor, selector: ({ editor }) => editor?.state }); + + useEffect(() => { + let live = true; + setAppleToolsAvailable(false); + if (writingTools && isMacDesktop) { + void writingToolsAvailable().then((available) => { if (live) setAppleToolsAvailable(available); }) + .catch((error) => { if (live) setWritingToolsError(String(error)); }); + } + return () => { live = false; }; + }, [writingTools]); + + useEffect(() => { + editor?.view.dom.setAttribute("spellcheck", String(!isMacDesktop && spelling)); + }, [editor, spelling]); + useEffect(() => { ready.current?.(editor ?? null); return () => ready.current?.(null); }, [editor]); - // Written from outside: Instant intro puts a line at the top and pressing it again takes the line - // away. Comparing against what this editor last emitted rather than against its own document is - // what keeps that from fighting the caret on every keystroke. useEffect(() => { if (!editor || html === emitted.current) return; emitted.current = html; editor.commands.setContent(html, { emitUpdate: false }); }, [editor, html]); + useEffect(() => { + if (!editor) return; + let version = 0; + let timer: ReturnType; + const proof = () => { + const current = ++version; + clearTimeout(timer); + setIssues([]); + editor.view.dispatch(editor.state.tr.setMeta(proofKey, [])); + if (!isMacDesktop || (!spelling && !grammar)) return; + timer = setTimeout(() => { + const doc = editor.state.doc; + const segments: { offset: number; position: number; text: string }[] = []; + let text = ""; + doc.descendants((node, position) => { + if (!node.isTextblock) return true; + if (text) text += "\n\n"; + node.forEach((child, offset) => { + if (child.type.name === "hardBreak") { text += "\n"; return; } + if (!child.isText) return; + segments.push({ offset: text.length, position: position + 1 + offset, text: child.text ?? "" }); + text += child.text ?? ""; + }); + return false; + }); + if (!text.trim()) return; + const positionAt = (offset: number) => { + const segment = segments.find((segment) => offset >= segment.offset && offset <= segment.offset + segment.text.length); + return segment ? segment.position + offset - segment.offset : null; + }; + void proofText(text, spelling, grammar).then((found) => { + if (current !== version || editor.isDestroyed || !editor.state.doc.eq(doc)) return; + const mapped = found.flatMap((issue) => { + const from = positionAt(issue.start); + const to = positionAt(issue.end); + return from !== null && to !== null && to > from ? [{ ...issue, from, to, word: text.slice(issue.start, issue.end) }] : []; + }); + setIssues(mapped); + setProofError(""); + editor.view.dispatch(editor.state.tr.setMeta(proofKey, mapped)); + }).catch((error) => { + if (current === version) setProofError(String(error)); + }); + }, 650); + }; + proof(); + editor.on("update", proof); + return () => { version++; clearTimeout(timer); editor.off("update", proof); }; + }, [editor, spelling, grammar]); + + useEscapeLayer(linkOpen || proofOpen, () => { setLinkOpen(false); setProofOpen(false); editor?.commands.focus(); }); + + const openLink = () => { + setLinkValue((editor?.getAttributes("link").href as string) ?? ""); + setLinkError(""); + setLinkOpen(true); + setProofOpen(false); + }; + + useEffect(() => { + if (!editor) return; + const key = (event: KeyboardEvent) => { + if (!editor.isFocused || event.key.toLowerCase() !== "k" || !(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return; + event.preventDefault(); + event.stopPropagation(); + openLink(); + }; + editor.view.dom.addEventListener("keydown", key); + return () => editor.view.dom.removeEventListener("keydown", key); + }, [editor]); + + const applyLink = () => { + if (!editor) return; + const value = linkValue.trim(); + if (!value) editor.chain().focus().extendMarkRange("link").unsetLink().run(); + else { + const href = /^[a-z][a-z\d+.-]*:/i.test(value) ? value : `https://${value}`; + try { + const url = new URL(href); + if (!["http:", "https:", "mailto:", "tel:"].includes(url.protocol)) throw new Error(); + } catch { setLinkError("Use a web, email, or telephone link."); return; } + if (editor.state.selection.empty && !editor.isActive("link")) { + editor.chain().focus().insertContent({ type: "text", text: value, marks: [{ type: "link", attrs: { href } }] }).run(); + } else editor.chain().focus().extendMarkRange("link").setLink({ href }).run(); + } + setLinkOpen(false); + }; + + const tool = (title: string, content: ReactNode, action: () => void, active = false, disabled = false) => ( + + ); + + const insertSignature = () => { + if (!editor || !signature) return; + editor.chain().focus().insertContent(signatureHtml(signature)).run(); + }; + + const runAppleTool = (tool: "Proofread" | "Rewrite") => { + if (!editor) return; + setWritingToolsError(""); + if (editor.state.selection.empty) editor.chain().focus().selectAll().run(); + else editor.commands.focus(); + requestAnimationFrame(() => { + void runWritingTool(tool).catch((error) => setWritingToolsError(String(error))); + }); + }; + return ( -
- {/* The placeholder is a sibling rather than the Placeholder extension, which is a dependency - this app does not have and would be carrying for one line of grey text. */} - - +
+ {editor ? <> +
+ {tool("Bold (⌘B)", B, () => editor.chain().focus().toggleBold().run(), editor.isActive("bold"))} + {tool("Italic (⌘I)", I, () => editor.chain().focus().toggleItalic().run(), editor.isActive("italic"))} + {tool("Underline (⌘U)", U, () => editor.chain().focus().toggleUnderline().run(), editor.isActive("underline"))} + {tool("Strikethrough", S, () => editor.chain().focus().toggleStrike().run(), editor.isActive("strike"))} + + {tool("Bulleted list", , () => editor.chain().focus().toggleBulletList().run(), editor.isActive("bulletList"))} + {tool("Numbered list", , () => editor.chain().focus().toggleOrderedList().run(), editor.isActive("orderedList"))} + {tool("Quote", , () => editor.chain().focus().toggleBlockquote().run(), editor.isActive("blockquote"))} + {tool("Link (⌘K)", , openLink, editor.isActive("link") || linkOpen)} + {tool("Clear formatting", , () => editor.chain().focus().unsetAllMarks().clearNodes().run())} + + {tool("Undo (⌘Z)", , () => editor.chain().focus().undo().run(), false, !editor.can().undo())} + {tool("Redo (⌘⇧Z)", , () => editor.chain().focus().redo().run(), false, !editor.can().redo())} + {signature?.trim() ? tool("Insert signature", , insertSignature) : null} + {writingTools && appleToolsAvailable ? <> + {tool("Proofread with Apple Writing Tools", "Proofread", () => runAppleTool("Proofread"), false, !hasText(html))} + {tool("Rewrite with Apple Writing Tools", "Rewrite", () => runAppleTool("Rewrite"), false, !hasText(html))} + : null} + {isMacDesktop && (spelling || grammar) ? tool(`Spelling and grammar${issues.length ? ` (${issues.length})` : ""}`, <>{issues.length ? {issues.length} : null}, () => { setProofOpen(!proofOpen); setLinkOpen(false); }, proofOpen) : null} +
+ {writingTools && writingToolsError ?

{writingToolsError}

: null} + {linkOpen ?
{ event.preventDefault(); applyLink(); }}> + setLinkValue(event.target.value)} /> + + {editor.isActive("link") ? : null} + + {linkError ? {linkError} : null} +
: null} + {proofOpen ?
+ {proofError ?

Writing check unavailable: {proofError}

: issues.length ? issues.map((issue, index) =>
+ + {issue.message} + {issue.suggestions.map((suggestion, suggestionIndex) => )} +
) :

No suggestions. Checks use your Mac’s available languages.

} +
: null} + : null} +
+ + +
); } diff --git a/src/screens/FocusReply.tsx b/src/screens/FocusReply.tsx index d78bad3..7219295 100644 --- a/src/screens/FocusReply.tsx +++ b/src/screens/FocusReply.tsx @@ -92,7 +92,7 @@ export function FocusReply() { setAt((was) => Math.min(Math.max(was + delta, 0), Math.max(items.length - 1, 0))); /** One item's reply, down the pipeline the other two composers use. */ - const post = (thread: ThreadSummary, now: boolean) => { + const post = async (thread: ThreadSummary, now: boolean) => { const view = views[thread.key]; const last = view?.messages.at(-1); const body = (drafts[thread.key] ?? "").trim(); @@ -108,7 +108,8 @@ export function FocusReply() { .join(""), }); const to = useCompose.getState().reply?.draft.to[0]; - void compose.post("reply", now); + await compose.post("reply", now); + if (useCompose.getState().reply) return; setSent((was) => ({ ...was, [thread.key]: to ? displayName(to) : displayName(thread.from) })); step(1); }; diff --git a/src/screens/Settings.tsx b/src/screens/Settings.tsx index 328acb1..5c74703 100644 --- a/src/screens/Settings.tsx +++ b/src/screens/Settings.tsx @@ -16,6 +16,7 @@ import { accountRemove, accountSetColor, accountSetName } from "../api/accounts" import { backupConfigure, backupNow, backupPhrase, backupRestore } from "../api/backup"; import { contactUpdate, contactsList } from "../api/contacts"; import { imapServers } from "../api/imap"; +import { otpAutofillEnable, otpAutofillStatus, type OtpAutofillStatus } from "../api/otpAutofill"; import { askForNotifications, notifyPermission, @@ -41,6 +42,7 @@ import { REQUIRED_SCOPE, SCOPES, isDesktop, + isMacDesktop, isTauri, type Account, type BackupSettings, @@ -1401,6 +1403,20 @@ function PrivacySection() { const save = useSettings((s) => s.save); const [allowed, setAllowed] = useState([]); const [phase, setPhase] = useState<"loading" | "idle" | "error">("loading"); + const [otpStatus, setOtpStatus] = useState(null); + const [otpBusy, setOtpBusy] = useState(false); + + useEffect(() => { + let alive = true; + void otpAutofillStatus().then((status) => { + if (alive) setOtpStatus(status); + }).catch((error) => { + if (!alive) return; + setOtpStatus({ available: false, enabled: false }); + notify(`Could not check Email OTP AutoFill: ${error}`); + }); + return () => { alive = false; }; + }, []); useEffect(() => { let alive = true; @@ -1420,6 +1436,25 @@ function PrivacySection() { if (!settings) return

Privacy

; + const changeOtpAutofill = async (enabled: boolean) => { + setOtpBusy(true); + try { + if (enabled && !otpStatus?.enabled) { + const activated = await otpAutofillEnable(); + if (!activated) { + notify("Email OTP AutoFill was not enabled in macOS."); + return; + } + setOtpStatus({ available: true, enabled: true }); + } + await save({ otpAutofillEnabled: enabled }); + } catch (error) { + notify(`Could not enable Email OTP AutoFill: ${error}`); + } finally { + setOtpBusy(false); + } + }; + const forget = (card: ContactCard) => { setAllowed((were) => were.filter((one) => one !== card)); void contactUpdate(card.accountId, card.person.address, { allowRemoteImages: false }).catch( @@ -1438,6 +1473,26 @@ function PrivacySection() { decided somewhere else.

+
+ void changeOtpAutofill(enabled)} /> +

+ {otpStatus?.available + ? "Enable Margin Mail under macOS System Settings → General → AutoFill & Passwords when prompted. Keep Margin Mail running and unlocked to receive codes." + : "Requires macOS 15 or later and the installed Margin Mail AutoFill extension."} +

+
+ +
+ void save({ appLockEnabled })} /> +
+ +
+ void save({ spellingEnabled })} /> +
+
+ void save({ grammarEnabled })} /> +
+
+ void save({ writingToolsEnabled })} /> +
+ span { flex-basis: 100%; } +.editor-proof-word { font-weight: 600; } +.editor-writing-error { margin: 0 0 12px; color: var(--ink-soft); font-size: var(--t-2); } diff --git a/src/signature.ts b/src/signature.ts new file mode 100644 index 0000000..8da4920 --- /dev/null +++ b/src/signature.ts @@ -0,0 +1,5 @@ +export function signatureHtml(signature: string): string { + if (/<\/?[a-z][^>]*>/i.test(signature)) return signature; + return signature.split("\n").map((line) => + `

${line.replace(/&/g, "&").replace(//g, ">")}

`).join(""); +} diff --git a/src/store/useCompose.ts b/src/store/useCompose.ts index d5990b9..5bf25ee 100644 --- a/src/store/useCompose.ts +++ b/src/store/useCompose.ts @@ -14,6 +14,7 @@ import { useAccounts } from "./useAccounts"; import { useMail } from "./useMail"; import { useSettings } from "./useSettings"; import { notify, useToast } from "./useToast"; +import { signatureHtml } from "../signature"; /** * Everything being written, and the one send that has not gone yet. @@ -195,7 +196,7 @@ function sendingAccount(): string | null { function signatureFor(accountId: string): string { const settings = useSettings.getState().settings; const signature = settings?.accounts.find((a) => a.accountId === accountId)?.signature ?? ""; - return signature ? `

${signature}

` : "

"; + return signature ? `

${signatureHtml(signature)}` : "

"; } /** The people a reply goes to, with you and the sender taken out of the extra ones. */ @@ -224,6 +225,14 @@ const escapeHtml = (text: string): string => const saveTimers: Record = { card: 0, reply: 0 }; const saveRequests: Partial>> = {}; +const attachmentRequests: Record>> = { card: new Set(), reply: new Set() }; + +export function storeAttachments(at: ComposerAt, work: Promise): Promise { + attachmentRequests[at].add(work); + const clear = () => attachmentRequests[at].delete(work); + void work.then(clear, clear); + return work; +} function scheduleSave(at: ComposerAt): void { if (typeof window === "undefined") return; @@ -277,19 +286,6 @@ function startBeat(): void { beat = window.setInterval(tick, 1000); } -/** - * The row this send just went out on, found in the outbox. - * - * `send` answers with an `Undo` and not with the `Outgoing` it queued, so the only handle on the - * queued message is the outbox itself. The one that is holding the longest is the one that was - * queued last, because every hold is the same length from the moment it was made. - */ -async function newestOutgoing(): Promise { - const outbox = await outboxList(); - if (outbox.length === 0) return null; - return outbox.reduce((latest, item) => (item.holdUntilMs > latest.holdUntilMs ? item : latest)).id; -} - export const useCompose = create((set, get) => ({ drafts: [], loadDrafts: async () => { @@ -366,6 +362,7 @@ export const useCompose = create((set, get) => ({ }, closeCard: async () => { + try { await Promise.all(attachmentRequests.card); } catch { return; } await get().save("card"); const card = get().card; if (!card || card.phase === "error") return; @@ -466,6 +463,7 @@ export const useCompose = create((set, get) => ({ }), closeReply: async () => { + try { await Promise.all(attachmentRequests.reply); } catch { return; } await get().save("reply"); const reply = get().reply; if (!reply || reply.phase === "error") return; @@ -601,6 +599,7 @@ export const useCompose = create((set, get) => ({ }, post: async (at, now) => { + try { await Promise.all(attachmentRequests[at]); } catch { return; } cancelSave(at); await saveRequests[at]; const composer = composerAt(get(), at); @@ -647,8 +646,7 @@ export const useCompose = create((set, get) => ({ // from here until the provider answers, and that can be twenty seconds. notify(`Sending to ${named(draft.to[0])}`); try { - const id = await newestOutgoing(); - if (id) await sendNow(id); + await sendNow(undo.token); } catch (e) { notify(`Could not skip the wait: ${e}`); void useMail.getState().load();