mirror of
https://github.com/priyanshujain/margin-mail.git
synced 2026-10-04 20:17:05 +00:00
some more fixes
This commit is contained in:
1 parent
7274a54a6e
commit
9fccd563f6
40 files changed
+1697
-111
No files matched your search
Generated
+14
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) }
|
||||
}
|
||||
Reference in new issue
Block a user