some more fixes

This commit is contained in:
pj committed 2026-10-03 23:24:24 +05:30
1 parent 7274a54a6e
commit 9fccd563f6
40 files changed
+1697 -111

No files matched your search

+14
View File
@@ -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"
+13
View File
@@ -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",
+136
View File
@@ -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<String>,
}
pub fn permits<R: tauri::Runtime>(invoke: &tauri::ipc::Invoke<R>) -> bool {
matches!(invoke.message.command(), "app_lock_status" | "app_unlock")
|| !invoke.message.state_ref().get::<AppLock>().is_locked()
}
pub fn is_locked(app: &tauri::AppHandle) -> bool {
app.try_state::<AppLock>()
.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::<AppLock>();
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::<AppLock>();
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()
}
+29
View File
@@ -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<DraftAttachment, String> {
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
+10
View File
@@ -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.
+25 -2
View File
@@ -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<dyn Fn(tauri::ipc::Invoke<tauri::Wry>) -> 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");
+3 -1
View File
@@ -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<NotifyTarget>) {
pub fn notify_take() -> Option<NotifyTarget> {
OPENED.lock().ok().and_then(|mut slot| slot.take())
}
+344
View File
@@ -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<bool, String> {
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<String> {
use std::collections::HashSet;
use std::sync::LazyLock;
static CUE: LazyLock<regex::Regex> = 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<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\b[A-Z0-9]{4,8}\b").unwrap());
static DURATION: LazyLock<regex::Regex> =
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::<String>()
.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::<crate::db::Db>();
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<String>>(3)?,
row.get::<_, i64>(4)?,
row.get::<_, Option<String>>(5)?,
))
})
.map_err(|error| error.to_string())?;
rows.collect::<Result<Vec<_>, _>>()
.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<Retained<NSObject>, String> {
let class =
AnyClass::get(c"ASCredentialIdentityStore").ok_or("macOS AutoFill is unavailable")?;
Ok(unsafe { msg_send![class, sharedStore] })
}
pub fn enabled() -> Result<bool, String> {
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<Result<bool, String>>) {
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<Retained<NSObject>> = Vec::new();
for code in codes {
let allocated: Allocated<NSObject> = unsafe { msg_send![service_class, alloc] };
let service: Retained<NSObject> = unsafe {
msg_send![allocated, initWithIdentifier: &*NSString::from_str(&code.domain), type: 0isize]
};
let allocated: Allocated<NSObject> = 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<bool, String> {
Ok(false)
}
pub fn publish(_: &[EmailCode], _: Option<&tauri::AppHandle>) -> Result<(), String> {
Ok(())
}
pub fn enable(sender: tokio::sync::oneshot::Sender<Result<bool, String>>) {
let _ = sender.send(Err("Email OTP AutoFill requires macOS".into()));
}
}
+90
View File
@@ -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<String>,
}
#[tauri::command]
pub async fn proof_text(text: String, spelling: bool, grammar: bool) -> Result<Vec<ProofIssue>, 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<ProofIssue> {
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::<NSValue>().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::<NSString>().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::<NSArray>().map(|values| values.iter().filter_map(|value| value.downcast_ref::<NSString>().map(|value| value.to_string())).take(5).collect()))
.unwrap_or_default();
issues.push(ProofIssue { start, end: start + length, kind: "grammar", message, suggestions });
}
}
}
}
issues
})
}
+13
View File
@@ -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<Settings, Str
let path = path(&app)?;
let before = load(&app)?;
let after = merge(&before, patch)?;
crate::app_lock::validate_setting_change(before.app_lock_enabled, after.app_lock_enabled)?;
if !before.otp_autofill_enabled && after.otp_autofill_enabled {
crate::otp_autofill::validate_enabled()?;
}
write(&path, &after)?;
if before.otp_autofill_enabled && !after.otp_autofill_enabled {
crate::otp_autofill::clear()?;
}
app.state::<crate::app_lock::AppLock>().configure(after.app_lock_enabled);
for (account_id, days) in window_changes(&before, &after) {
crate::sync::window_set(&app, &account_id, days)?;
+15 -2
View File
@@ -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<S: Store>(
&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);
+27 -10
View File
@@ -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<tauri::State<'_, Db>, 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<tokio::time::Instant> = 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);
}
}
+77
View File
@@ -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<Retained<NSMenu>> {
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()) }
}