mirror of
https://github.com/flectar/mail.git
synced 2026-11-11 17:57:54 +00:00
feat: open mailto links from other apps
This commit is contained in:
1 parent
5c99506e82
commit
d6c9fdf6c7
23 files changed
+4007
-3063
No files matched your search
+2
-2
@@ -172,8 +172,8 @@ slint = { version = "1.18.1", default-features = false, features = ["backend-win
|
||||
# These AppKit bindings are already in the build.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = { version = "0.6", default-features = false, features = ["std"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "NSApplication", "NSButton", "NSControl", "NSImage", "NSResponder", "NSView", "NSWindow"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "NSAppleEventManager", "NSData", "NSError", "NSGeometry", "NSString", "NSUserDefaults"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "NSApplication", "NSButton", "NSControl", "NSImage", "NSResponder", "NSRunningApplication", "NSView", "NSWindow"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "NSAppleEventManager", "NSData", "NSError", "NSGeometry", "NSNotification", "NSString", "NSUserDefaults"] }
|
||||
# Registers the bundled sign-in agent with Login Items (macOS 13+).
|
||||
objc2-service-management = { version = "0.3", default-features = false, features = ["std", "objc2", "objc2-foundation", "SMAppService"] }
|
||||
|
||||
|
||||
@@ -3341,6 +3341,25 @@ impl Core {
|
||||
Ok(detail)
|
||||
}
|
||||
|
||||
/// The stored message of `account_id` with this RFC Message-ID (with or
|
||||
/// without angle brackets), so a mailto link's In-Reply-To can thread the
|
||||
/// reply it starts.
|
||||
pub async fn message_by_header_id(
|
||||
&self,
|
||||
account_id: i64,
|
||||
message_id: &str,
|
||||
) -> Result<Option<i64>> {
|
||||
let message_id = message_id.trim().trim_matches(['<', '>']).to_owned();
|
||||
if message_id.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
self.db
|
||||
.read(move |conn| {
|
||||
Ok(repo::messages::by_message_id(conn, account_id, &message_id)?.map(|row| row.id))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Newest message of `thread_id` that can be unsubscribed from, or None
|
||||
/// when the thread genuinely offers no list unsubscribe.
|
||||
///
|
||||
@@ -10375,6 +10394,51 @@ mod startup_connection_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod message_by_header_id_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn finds_the_account_message_with_or_without_brackets() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let core = Core::start_mail_ui(Paths::for_tests(temp.path()))
|
||||
.await
|
||||
.unwrap();
|
||||
let id = core
|
||||
.db
|
||||
.write(|conn| {
|
||||
db::testutil::seed_account(conn);
|
||||
let (_, id) = db::testutil::seed_message(conn, "list@test.dev", "Topic", false);
|
||||
conn.execute(
|
||||
"UPDATE messages SET message_id = 'abc.123@mail.test' WHERE id = ?1",
|
||||
[id],
|
||||
)?;
|
||||
Ok(id)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for header in [
|
||||
"<abc.123@mail.test>",
|
||||
"abc.123@mail.test",
|
||||
" <abc.123@mail.test> ",
|
||||
] {
|
||||
assert_eq!(
|
||||
core.message_by_header_id(1, header).await.unwrap(),
|
||||
Some(id)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
core.message_by_header_id(2, "<abc.123@mail.test>")
|
||||
.await
|
||||
.unwrap(),
|
||||
None,
|
||||
"another account's message must not be found"
|
||||
);
|
||||
assert_eq!(core.message_by_header_id(1, "<>").await.unwrap(), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod draft_staging_tests {
|
||||
use super::*;
|
||||
|
||||
+288
-270
File diff suppressed because it is too large.
Load diff
+288
-270
File diff suppressed because it is too large.
Load diff
+290
-270
File diff suppressed because it is too large.
Load diff
+308
-290
File diff suppressed because it is too large.
Load diff
+288
-270
File diff suppressed because it is too large.
Load diff
+288
-270
File diff suppressed because it is too large.
Load diff
+288
-270
File diff suppressed because it is too large.
Load diff
+288
-270
File diff suppressed because it is too large.
Load diff
+290
-270
File diff suppressed because it is too large.
Load diff
+288
-270
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -386,6 +386,8 @@ pub(super) fn refresh_connected_accounts(app: &AppWindow, state: &Rc<RefCell<Inb
|
||||
app.global::<AccountMailPreferences>()
|
||||
.invoke_context_changed();
|
||||
app.global::<FilesUi>().invoke_context_changed();
|
||||
// A link that waited for an account to send from can open now.
|
||||
mailto_links::open_pending_soon(app);
|
||||
}
|
||||
|
||||
/// Show the mail kept on this device, or that there is none.
|
||||
|
||||
+11
@@ -2018,6 +2018,17 @@ impl CoreMailSource {
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub async fn message_by_header_id(
|
||||
&self,
|
||||
account_id: i64,
|
||||
message_id: &str,
|
||||
) -> Result<Option<i64>, String> {
|
||||
self.core
|
||||
.message_by_header_id(account_id, message_id)
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub async fn save_new_draft(&self, message: ComposeMessage<'_>) -> Result<i64, String> {
|
||||
self.save_draft(compose_args(message, false)?).await
|
||||
}
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
//! Opening mailto links, whether clicked in a message or handed over by the
|
||||
//! system when Flectar Mail is the default email app.
|
||||
//!
|
||||
//! Links wait until the mail store is ready and an account can send, then
|
||||
//! each opens the composer pre-filled for the user to review; nothing is sent
|
||||
//! on a link's behalf. The composer holds one message, so a link never
|
||||
//! replaces what the user is writing: a blank composer is reused, anything
|
||||
//! else is saved to Drafts first, where other clients open another window.
|
||||
|
||||
use super::*;
|
||||
use flectar_mail_core::mailto::{self, Mailbox, Mailto};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
|
||||
/// More mail links than this in one launch are ignored.
|
||||
#[cfg(desktop)]
|
||||
pub(crate) const MAX_LAUNCH_LINKS: usize = 16;
|
||||
|
||||
/// The option the Windows registration puts before the link it passes:
|
||||
/// `flectar-mail.exe --mailto "%1"`.
|
||||
#[cfg(desktop)]
|
||||
pub(crate) const MAILTO_OPTION: &str = "--mailto";
|
||||
|
||||
/// The mail links a launch was opened with, or `None` for a launch that is
|
||||
/// not about mail links, whose options then apply as usual.
|
||||
///
|
||||
/// - `--mailto <link>` must be the whole command line, as Thunderbird
|
||||
/// requires of its `-osint` launches: a link that broke out of its quotes
|
||||
/// could otherwise add options such as `--background`. Anything else after
|
||||
/// `--mailto` opens nothing.
|
||||
/// - Desktop entries pass each link as its own argument (`%U`), so every
|
||||
/// `mailto:` argument is a link and other arguments are ignored.
|
||||
#[cfg(desktop)]
|
||||
pub(crate) fn launch_links(
|
||||
arguments: impl IntoIterator<Item = std::ffi::OsString>,
|
||||
) -> Option<Vec<String>> {
|
||||
let arguments = arguments.into_iter().collect::<Vec<_>>();
|
||||
if arguments.iter().any(|argument| argument == MAILTO_OPTION) {
|
||||
return Some(match arguments.as_slice() {
|
||||
[option, link] if option == MAILTO_OPTION => link
|
||||
.to_str()
|
||||
.filter(|link| mailto::is_mailto(link))
|
||||
.map(|link| vec![link.to_owned()])
|
||||
.unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
});
|
||||
}
|
||||
let links = arguments
|
||||
.iter()
|
||||
.filter_map(|argument| argument.to_str())
|
||||
.filter(|argument| mailto::is_mailto(argument))
|
||||
.take(MAX_LAUNCH_LINKS)
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
(!links.is_empty()).then_some(links)
|
||||
}
|
||||
|
||||
/// What opening a link needs from the rest of the application.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Composer {
|
||||
pub(super) state: Rc<RefCell<InboxState>>,
|
||||
pub(super) runtime: Rc<tokio::runtime::Runtime>,
|
||||
pub(super) intent: Rc<RefCell<ComposeIntent>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Links {
|
||||
pending: VecDeque<Mailto>,
|
||||
composer: Option<Composer>,
|
||||
scheduled: bool,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static LINKS: RefCell<Links> = RefCell::default();
|
||||
}
|
||||
|
||||
/// Connect the composer. Links received before this wait for it.
|
||||
pub(super) fn install(app: &AppWindow, composer: Composer) {
|
||||
LINKS.with(|links| links.borrow_mut().composer = Some(composer));
|
||||
open_pending_soon(app);
|
||||
}
|
||||
|
||||
/// Open the mail links macOS hands to the application. They arrive as Apple
|
||||
/// Events rather than on the command line, also for the launch itself, and
|
||||
/// bring the window back like any other launch.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn receive_from_macos(app: &AppWindow) {
|
||||
let app = app.as_weak();
|
||||
macos::on_open_url(move |url| {
|
||||
if !mailto::is_mailto(&url) {
|
||||
return;
|
||||
}
|
||||
let Some(app) = app.upgrade() else {
|
||||
return;
|
||||
};
|
||||
window_controller::activate(&app, None);
|
||||
open(&app, [url]);
|
||||
});
|
||||
}
|
||||
|
||||
/// Open mailto links in the composer as soon as it can take them.
|
||||
pub(super) fn open<S: AsRef<str>>(app: &AppWindow, uris: impl IntoIterator<Item = S>) {
|
||||
let mut unreadable = false;
|
||||
LINKS.with(|links| {
|
||||
let mut links = links.borrow_mut();
|
||||
for uri in uris {
|
||||
match mailto::parse(uri.as_ref()) {
|
||||
Some(message) => links.pending.push_back(message),
|
||||
None => unreadable = true,
|
||||
}
|
||||
}
|
||||
});
|
||||
if unreadable {
|
||||
app.set_render_status(UiMessage::plain("This email link is too long to open."));
|
||||
}
|
||||
open_pending_soon(app);
|
||||
}
|
||||
|
||||
/// Try the waiting links again on the next turn of the event loop: after
|
||||
/// startup, when accounts change and when the composer closes. Deferring keeps
|
||||
/// this out of whatever borrow of the application state the caller holds.
|
||||
pub(super) fn open_pending_soon(app: &AppWindow) {
|
||||
let idle = LINKS.with(|links| {
|
||||
let mut links = links.borrow_mut();
|
||||
let idle = !links.pending.is_empty() && !links.scheduled;
|
||||
links.scheduled |= idle;
|
||||
idle
|
||||
});
|
||||
if !idle {
|
||||
return;
|
||||
}
|
||||
let app = app.as_weak();
|
||||
Timer::single_shot(Duration::ZERO, move || {
|
||||
LINKS.with(|links| links.borrow_mut().scheduled = false);
|
||||
if let Some(app) = app.upgrade() {
|
||||
open_pending(&app);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn open_pending(app: &AppWindow) {
|
||||
while let Some((message, composer)) = LINKS.with(|links| {
|
||||
let links = links.borrow();
|
||||
Some((links.pending.front()?.clone(), links.composer.clone()?))
|
||||
}) {
|
||||
if !app.get_startup_ready() {
|
||||
return;
|
||||
}
|
||||
if composer.state.borrow().connected_accounts.is_empty() {
|
||||
app.set_sync_status(UiMessage::plain("Add an account to send mail."));
|
||||
return;
|
||||
}
|
||||
let Some(saved_previous) = make_room(app) else {
|
||||
return;
|
||||
};
|
||||
if !compose(app, &composer, &message, saved_previous) {
|
||||
return;
|
||||
}
|
||||
LINKS.with(|links| links.borrow_mut().pending.pop_front());
|
||||
}
|
||||
}
|
||||
|
||||
/// Close a blank composer, or save what the user is writing to Drafts.
|
||||
/// `Some(true)` when a message was saved; `None` when the composer has to
|
||||
/// stay, because it is sending or could not be saved (its notice says why).
|
||||
fn make_room(app: &AppWindow) -> Option<bool> {
|
||||
if !app.get_compose_open() {
|
||||
return Some(false);
|
||||
}
|
||||
if app.get_compose_sending() {
|
||||
return None;
|
||||
}
|
||||
if composer_is_blank(app) {
|
||||
app.invoke_close_compose();
|
||||
return (!app.get_compose_open()).then_some(false);
|
||||
}
|
||||
app.invoke_save_compose(
|
||||
app.get_compose_account_id(),
|
||||
app.get_compose_to(),
|
||||
app.get_compose_cc(),
|
||||
app.get_compose_bcc(),
|
||||
app.get_compose_subject(),
|
||||
app.get_compose_body(),
|
||||
false,
|
||||
);
|
||||
if app.get_compose_open() {
|
||||
app.set_sync_status(UiMessage::plain(
|
||||
"The email link will open when you close this message.",
|
||||
));
|
||||
return None;
|
||||
}
|
||||
Some(true)
|
||||
}
|
||||
|
||||
fn composer_is_blank(app: &AppWindow) -> bool {
|
||||
[
|
||||
app.get_compose_to(),
|
||||
app.get_compose_cc(),
|
||||
app.get_compose_bcc(),
|
||||
app.get_compose_subject(),
|
||||
app.get_compose_body(),
|
||||
]
|
||||
.iter()
|
||||
.all(|text| text.trim().is_empty())
|
||||
&& app.get_compose_attachments().row_count() == 0
|
||||
}
|
||||
|
||||
fn compose(app: &AppWindow, composer: &Composer, message: &Mailto, saved_previous: bool) -> bool {
|
||||
app.invoke_open_compose();
|
||||
if !app.get_compose_open() {
|
||||
return false;
|
||||
}
|
||||
app.set_compose_to(recipient_field(&message.to).into());
|
||||
app.set_compose_cc(recipient_field(&message.cc).into());
|
||||
app.set_compose_bcc(recipient_field(&message.bcc).into());
|
||||
if let Some(subject) = &message.subject {
|
||||
app.set_compose_subject(subject.into());
|
||||
}
|
||||
if let Some(body) = &message.body {
|
||||
app.invoke_edit_compose_body(body.into(), 0, 0, "".into());
|
||||
}
|
||||
if let Some(header_id) = &message.in_reply_to {
|
||||
thread_reply(app, composer, header_id);
|
||||
}
|
||||
if !message.attachment_requests.is_empty() {
|
||||
app.set_compose_notice(UiMessage::detail(
|
||||
"Files from links are never attached for you. Attach them yourself if you trust them: {}",
|
||||
message.attachment_requests.join(", "),
|
||||
));
|
||||
app.set_compose_notice_is_error(false);
|
||||
} else if saved_previous {
|
||||
app.set_compose_notice(UiMessage::plain(
|
||||
"The message you were writing was saved to Drafts.",
|
||||
));
|
||||
app.set_compose_notice_is_error(false);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Make the message a reply when the account it is written from has the
|
||||
/// message the link replies to, so it threads like any other reply. The
|
||||
/// sending account is never changed for a link.
|
||||
fn thread_reply(app: &AppWindow, composer: &Composer, header_id: &str) {
|
||||
let Some(core) = composer.state.borrow().core.clone() else {
|
||||
return;
|
||||
};
|
||||
let account_id = i64::from(app.get_compose_account_id());
|
||||
let parent = composer
|
||||
.runtime
|
||||
.block_on(core.message_by_header_id(account_id, header_id));
|
||||
if let Ok(Some(parent)) = parent {
|
||||
let mut intent = composer.intent.borrow_mut();
|
||||
intent.mode = "reply".to_owned();
|
||||
intent.in_reply_to_message_id = Some(parent);
|
||||
app.set_compose_mode("reply".into());
|
||||
}
|
||||
}
|
||||
|
||||
/// The composer's recipient syntax: entries separated by commas, each
|
||||
/// `Name <address>` or a bare address. Names lose the characters that syntax
|
||||
/// gives meaning to.
|
||||
fn recipient_field(mailboxes: &[Mailbox]) -> String {
|
||||
mailboxes
|
||||
.iter()
|
||||
.map(|mailbox| {
|
||||
let name = mailbox.name.as_deref().map(|name| {
|
||||
name.chars()
|
||||
.filter(|c| !matches!(c, ',' | ';' | '<' | '>' | '"'))
|
||||
.collect::<String>()
|
||||
});
|
||||
match name.as_deref().map(str::trim) {
|
||||
Some(name) if !name.is_empty() => format!("{name} <{}>", mailbox.email),
|
||||
_ => mailbox.email.clone(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[test]
|
||||
fn launch_links_come_only_from_mail_link_command_lines() {
|
||||
let links = |arguments: &[&str]| {
|
||||
launch_links(
|
||||
arguments
|
||||
.iter()
|
||||
.map(|argument| std::ffi::OsString::from(*argument)),
|
||||
)
|
||||
};
|
||||
assert_eq!(links(&[]), None);
|
||||
assert_eq!(links(&["--background"]), None);
|
||||
assert_eq!(
|
||||
links(&["--mailto", "mailto:a@example.com?subject=x"]),
|
||||
Some(vec!["mailto:a@example.com?subject=x".into()])
|
||||
);
|
||||
assert_eq!(
|
||||
links(&["MAILTO:a@example.com", "--verbose", "mailto:"]),
|
||||
Some(vec!["MAILTO:a@example.com".into(), "mailto:".into()])
|
||||
);
|
||||
// A link that escaped its quotes cannot add options or open anything.
|
||||
for arguments in [
|
||||
&["--mailto", "mailto:a@example.com", "--background", ""][..],
|
||||
&["--mailto", "mailto:a@example.com\\", "--background"],
|
||||
&["--background", "--mailto", "mailto:a@example.com"],
|
||||
&["--mailto", "https://example.com"],
|
||||
&["--mailto"],
|
||||
] {
|
||||
assert_eq!(links(arguments), Some(Vec::new()), "{arguments:?}");
|
||||
}
|
||||
let many = vec!["mailto:a@example.com"; MAX_LAUNCH_LINKS + 1];
|
||||
assert_eq!(links(&many).unwrap().len(), MAX_LAUNCH_LINKS);
|
||||
}
|
||||
|
||||
fn mailbox(name: Option<&str>, email: &str) -> Mailbox {
|
||||
Mailbox {
|
||||
name: name.map(str::to_owned),
|
||||
email: email.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipient_field_round_trips_through_the_composer_syntax() {
|
||||
let field = recipient_field(&[
|
||||
mailbox(Some("Doe, Jane"), "jane@example.com"),
|
||||
mailbox(None, "bob+list@example.com"),
|
||||
mailbox(Some(" <\"> "), "x@example.com"),
|
||||
]);
|
||||
assert_eq!(
|
||||
field,
|
||||
"Doe Jane <jane@example.com>, bob+list@example.com, x@example.com"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Receive the links macOS opens with Flectar Mail.
|
||||
//!
|
||||
//! macOS passes a URL to the application as a get-URL Apple Event, never on
|
||||
//! the command line, also when the link is what launched it. AppKit turns the
|
||||
//! event into `application:openURLs:`, which winit's application delegate
|
||||
//! does not implement, so it is handled here.
|
||||
//!
|
||||
//! The handler has to be registered when AppKit finishes launching: AppKit
|
||||
//! installs its own handlers first, replacing any registered earlier, and
|
||||
//! delivers the event that launched the application right after. Apple's
|
||||
//! scripting guide names `applicationWillFinishLaunching:` for this, and
|
||||
//! later registration would lose the launch link (Zed hit exactly that).
|
||||
|
||||
use objc2::rc::Retained;
|
||||
use objc2::runtime::AnyObject;
|
||||
use objc2::{MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
|
||||
use objc2_app_kit::{NSApplicationWillFinishLaunchingNotification, NSRunningApplication};
|
||||
use objc2_foundation::{NSAppleEventManager, NSNotificationCenter, NSObject, NSString};
|
||||
use std::cell::{OnceCell, RefCell};
|
||||
|
||||
/// `kInternetEventClass`, `kAEGetURL` and `keyDirectObject`.
|
||||
const INTERNET_EVENT_CLASS: u32 = u32::from_be_bytes(*b"GURL");
|
||||
const GET_URL: u32 = u32::from_be_bytes(*b"GURL");
|
||||
const DIRECT_OBJECT: u32 = u32::from_be_bytes(*b"----");
|
||||
|
||||
/// Receives each URL on the main thread.
|
||||
type UrlCallback = Box<dyn Fn(String)>;
|
||||
|
||||
thread_local! {
|
||||
/// Neither NSAppleEventManager nor NSNotificationCenter retains it.
|
||||
static HANDLER: OnceCell<Retained<UrlHandler>> = const { OnceCell::new() };
|
||||
static CALLBACK: RefCell<Option<UrlCallback>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
define_class!(
|
||||
#[unsafe(super(NSObject))]
|
||||
#[thread_kind = MainThreadOnly]
|
||||
#[name = "FlectarMailUrlHandler"]
|
||||
struct UrlHandler;
|
||||
|
||||
impl UrlHandler {
|
||||
#[unsafe(method(applicationWillFinishLaunching:))]
|
||||
fn will_finish_launching(&self, _notification: &AnyObject) {
|
||||
register(self);
|
||||
}
|
||||
|
||||
#[unsafe(method(handleGetURL:withReplyEvent:))]
|
||||
fn handle_get_url(&self, event: &AnyObject, _reply: &AnyObject) {
|
||||
// SAFETY: `event` is the NSAppleEventDescriptor of a get-URL
|
||||
// event; both selectors return a retained object or nil.
|
||||
// `paramDescriptorForKeyword:` takes an AEKeyword (UInt32).
|
||||
let url: Option<Retained<NSString>> = unsafe {
|
||||
let parameter: Option<Retained<AnyObject>> =
|
||||
msg_send![event, paramDescriptorForKeyword: DIRECT_OBJECT];
|
||||
match parameter {
|
||||
Some(parameter) => msg_send![¶meter, stringValue],
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
let Some(url) = url else {
|
||||
return;
|
||||
};
|
||||
CALLBACK.with(|callback| {
|
||||
if let Some(callback) = callback.borrow().as_ref() {
|
||||
callback(url.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/// Call `callback` with each URL macOS opens with the application. Call it
|
||||
/// before the event loop starts, so the link a launch was started for
|
||||
/// arrives too.
|
||||
pub(super) fn on_open_url(callback: impl Fn(String) + 'static) {
|
||||
let Some(main_thread) = MainThreadMarker::new() else {
|
||||
return;
|
||||
};
|
||||
CALLBACK.with(|slot| *slot.borrow_mut() = Some(Box::new(callback)));
|
||||
HANDLER.with(|handler| {
|
||||
let handler = handler.get_or_init(|| {
|
||||
// SAFETY: `init` is NSObject's designated initializer.
|
||||
unsafe { msg_send![UrlHandler::alloc(main_thread), init] }
|
||||
});
|
||||
// Registering now would be overwritten by AppKit's own handler,
|
||||
// unless launching is already over.
|
||||
if NSRunningApplication::currentApplication().isFinishedLaunching() {
|
||||
register(handler);
|
||||
return;
|
||||
}
|
||||
// SAFETY: the handler implements the selector with the signature a
|
||||
// notification observer needs, and HANDLER keeps it alive for the
|
||||
// life of the thread.
|
||||
unsafe {
|
||||
NSNotificationCenter::defaultCenter().addObserver_selector_name_object(
|
||||
handler,
|
||||
sel!(applicationWillFinishLaunching:),
|
||||
Some(NSApplicationWillFinishLaunchingNotification),
|
||||
None,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn register(handler: &UrlHandler) {
|
||||
let manager = NSAppleEventManager::sharedAppleEventManager();
|
||||
// SAFETY: the handler implements the selector with the signature
|
||||
// NSAppleEventManager calls, and HANDLER keeps it alive.
|
||||
unsafe {
|
||||
let _: () = msg_send![
|
||||
&manager,
|
||||
setEventHandler: handler,
|
||||
andSelector: sel!(handleGetURL:withReplyEvent:),
|
||||
forEventClass: INTERNET_EVENT_CLASS,
|
||||
andEventID: GET_URL,
|
||||
];
|
||||
}
|
||||
}
|
||||
+30
-6
@@ -32,6 +32,7 @@ mod mail_scope;
|
||||
mod mail_setup;
|
||||
mod mail_view_model;
|
||||
mod mail_work;
|
||||
mod mailto_links;
|
||||
mod message_appearance;
|
||||
mod notifications;
|
||||
pub use notifications::{NewMailNotification, NotificationPublisher};
|
||||
@@ -1588,16 +1589,20 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
|
||||
.build()?,
|
||||
);
|
||||
#[cfg(desktop)]
|
||||
let background_launch = autostart::launched_in_background();
|
||||
let launch_links = mailto_links::launch_links(std::env::args_os().skip(1));
|
||||
// A mail link launch always shows the window, whatever else it says.
|
||||
#[cfg(desktop)]
|
||||
let background_launch = launch_links.is_none() && autostart::launched_in_background();
|
||||
#[cfg(desktop)]
|
||||
let mut instance = {
|
||||
use single_instance::{Acquired, Request};
|
||||
use single_instance::{Acquired, Activation, Request};
|
||||
let request = if background_launch {
|
||||
Request::Nothing
|
||||
} else {
|
||||
Request::Show {
|
||||
Request::Show(Activation {
|
||||
activation_token: window_chrome::launch_activation_token(),
|
||||
}
|
||||
links: launch_links.clone().unwrap_or_default(),
|
||||
})
|
||||
};
|
||||
match single_instance::acquire(&runtime, &platform.paths.data_dir, request) {
|
||||
Ok(Acquired::Primary(primary)) => Some(primary),
|
||||
@@ -1796,12 +1801,16 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
|
||||
#[cfg(desktop)]
|
||||
if let Some(instance) = instance.as_mut() {
|
||||
let app = app.as_weak();
|
||||
instance.serve(&runtime, move |activation_token| {
|
||||
instance.serve(&runtime, move |activation| {
|
||||
let _ = app.upgrade_in_event_loop(move |app| {
|
||||
window_controller::activate(&app, activation_token);
|
||||
window_controller::activate(&app, activation.activation_token);
|
||||
mailto_links::open(&app, activation.links);
|
||||
});
|
||||
});
|
||||
}
|
||||
// They open once the mail store is ready and the composer connected.
|
||||
#[cfg(desktop)]
|
||||
mailto_links::open(&app, launch_links.unwrap_or_default());
|
||||
// The launcher's token was meant for this window: it lets the desktop
|
||||
// focus it (Wayland) and ends the launch feedback (X11).
|
||||
#[cfg(desktop)]
|
||||
@@ -4367,6 +4376,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
|
||||
refresh_list_metadata(&app, &startup_state);
|
||||
app.set_startup_hydrated(true);
|
||||
app.set_startup_ready(true);
|
||||
mailto_links::open_pending_soon(&app);
|
||||
app.invoke_product_changed(app.get_active_view());
|
||||
app.global::<TabsUi>().invoke_restore_active();
|
||||
app.set_startup_failed(false);
|
||||
@@ -6181,6 +6191,17 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
|
||||
}
|
||||
});
|
||||
|
||||
mailto_links::install(
|
||||
&app,
|
||||
mailto_links::Composer {
|
||||
state: Rc::clone(&state),
|
||||
runtime: Rc::clone(&runtime),
|
||||
intent: Rc::clone(&compose_intent),
|
||||
},
|
||||
);
|
||||
#[cfg(target_os = "macos")]
|
||||
mailto_links::receive_from_macos(&app);
|
||||
|
||||
let app_weak = app.as_weak();
|
||||
let state_for_message_compose = Rc::clone(&state);
|
||||
let runtime_for_message_compose = Rc::clone(&runtime);
|
||||
@@ -6443,6 +6464,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
|
||||
&contacts_for_close,
|
||||
);
|
||||
*intent_for_close.borrow_mut() = ComposeIntent::default();
|
||||
mailto_links::open_pending_soon(&app);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6947,6 +6969,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
|
||||
app.set_sync_status(message);
|
||||
let _ =
|
||||
refresh_from_source(&app, &state_for_compose, &runtime_for_compose, true, &[]);
|
||||
mailto_links::open_pending_soon(&app);
|
||||
}
|
||||
Err(error) => {
|
||||
let message = UiMessage::detail("Compose failed: {}", error);
|
||||
@@ -6997,6 +7020,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
|
||||
true,
|
||||
&[],
|
||||
);
|
||||
mailto_links::open_pending_soon(&app);
|
||||
}
|
||||
Err(error) => {
|
||||
let message = UiMessage::detail(
|
||||
|
||||
@@ -89,10 +89,13 @@ fn automatic_selection_hydrates_and_reader_controls_are_responsive_and_keyboard_
|
||||
assert_eq!(app.get_render_message().argument, "test host unavailable");
|
||||
app.invoke_open_email_link("javascript:alert(1)".into());
|
||||
app.invoke_open_email_link("#fragment".into());
|
||||
app.invoke_open_email_link("mailto:person@example.com?subject=Hello%20there".into());
|
||||
assert_eq!(app.get_compose_to(), "person@example.com");
|
||||
assert_eq!(app.get_compose_subject(), "Hello there");
|
||||
// Mail links never go to the browser. This one waits for the composer,
|
||||
// which is connected at the end of the test.
|
||||
app.invoke_open_email_link(
|
||||
"mailto:person+list@example.com?subject=Hello+there%21&bcc=hidden@example.com".into(),
|
||||
);
|
||||
assert_eq!(opened_urls.lock().unwrap().len(), 1);
|
||||
assert!(!app.get_compose_open());
|
||||
let requests = Rc::new(RefCell::new(Vec::new()));
|
||||
let requests2 = requests.clone();
|
||||
app.global::<EmailReader>()
|
||||
@@ -766,9 +769,95 @@ fn automatic_selection_hydrates_and_reader_controls_are_responsive_and_keyboard_
|
||||
&mut directory.borrow_mut()
|
||||
));
|
||||
assert!(directory.borrow().contacts.is_empty());
|
||||
app.set_active_view("mail".into());
|
||||
mailto_links_open_without_losing_the_draft(&app, &state, &runtime);
|
||||
app.hide().unwrap();
|
||||
}
|
||||
|
||||
fn mailto_links_open_without_losing_the_draft(
|
||||
app: &AppWindow,
|
||||
state: &Rc<RefCell<InboxState>>,
|
||||
runtime: &Rc<tokio::runtime::Runtime>,
|
||||
) {
|
||||
state.borrow_mut().connected_accounts = vec![Account {
|
||||
id: 1,
|
||||
email: "me@example.test".into(),
|
||||
display_name: None,
|
||||
avatar_url: None,
|
||||
provider: Provider::Generic,
|
||||
auth_kind: flectar_mail_core::models::AuthKind::Password,
|
||||
mail_protocol: MailProtocol::Imap,
|
||||
sync_state: "idle".into(),
|
||||
sync_error: None,
|
||||
can_create_top_level_mailbox: true,
|
||||
server_keeps_mail_days: None,
|
||||
}];
|
||||
app.set_compose_account_id(1);
|
||||
let weak = app.as_weak();
|
||||
app.on_open_compose(move || weak.unwrap().set_compose_open(true));
|
||||
let saved = Rc::new(RefCell::new(Vec::new()));
|
||||
let weak = app.as_weak();
|
||||
let recorded = saved.clone();
|
||||
app.on_save_compose(move |_, to, _, _, subject, _, send| {
|
||||
assert!(!send, "a link must never send");
|
||||
recorded
|
||||
.borrow_mut()
|
||||
.push((to.to_string(), subject.to_string()));
|
||||
let app = weak.unwrap();
|
||||
for clear in [
|
||||
AppWindow::set_compose_to,
|
||||
AppWindow::set_compose_cc,
|
||||
AppWindow::set_compose_bcc,
|
||||
AppWindow::set_compose_subject,
|
||||
AppWindow::set_compose_body,
|
||||
] {
|
||||
clear(&app, "".into());
|
||||
}
|
||||
app.set_compose_open(false);
|
||||
});
|
||||
let frame = || {
|
||||
slint::platform::update_timers_and_animations();
|
||||
let _ = app.window().size();
|
||||
};
|
||||
|
||||
mailto_links::install(
|
||||
app,
|
||||
mailto_links::Composer {
|
||||
state: Rc::clone(state),
|
||||
runtime: Rc::clone(runtime),
|
||||
intent: Rc::new(RefCell::new(ComposeIntent::default())),
|
||||
},
|
||||
);
|
||||
frame();
|
||||
assert!(app.get_compose_open(), "the waiting link opens once it can");
|
||||
assert_eq!(app.get_compose_to(), "person+list@example.com");
|
||||
assert_eq!(app.get_compose_subject(), "Hello+there!");
|
||||
assert_eq!(app.get_compose_bcc(), "hidden@example.com");
|
||||
|
||||
app.set_compose_subject("My own subject".into());
|
||||
// The reader's link handler was replaced by a recorder above; open the
|
||||
// link the way it does.
|
||||
mailto_links::open(app, ["mailto:second@example.com?attach=/etc/passwd"]);
|
||||
frame();
|
||||
assert_eq!(
|
||||
*saved.borrow(),
|
||||
[(
|
||||
"person+list@example.com".to_owned(),
|
||||
"My own subject".to_owned()
|
||||
)],
|
||||
"what the user wrote is saved to Drafts before the next link opens"
|
||||
);
|
||||
assert!(app.get_compose_open());
|
||||
assert_eq!(app.get_compose_to(), "second@example.com");
|
||||
assert_eq!(app.get_compose_bcc(), "");
|
||||
assert_eq!(
|
||||
app.get_compose_message().key,
|
||||
"Files from links are never attached for you. Attach them yourself if you trust them: {}"
|
||||
);
|
||||
assert_eq!(app.get_compose_message().argument, "/etc/passwd");
|
||||
assert!(app.get_compose_attachments().row_count() == 0);
|
||||
}
|
||||
|
||||
/// The desktop mail view at scale 2, drawn by Slint's software renderer like
|
||||
/// CPU mode. That renderer panics when it paints a rectangle reaching past
|
||||
/// ±32767 physical pixels (#35), so reader surfaces sized by a long message
|
||||
|
||||
@@ -115,26 +115,8 @@ pub(super) fn register_renderer_input_callbacks(
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Ok(parsed) = url::Url::parse(&url)
|
||||
&& parsed.scheme() == "mailto"
|
||||
{
|
||||
app.invoke_open_compose();
|
||||
// Form decoding also handles UTF-8 and percent escapes in addresses.
|
||||
let address = percent_encoding::percent_decode_str(parsed.path())
|
||||
.decode_utf8_lossy()
|
||||
.into_owned();
|
||||
app.set_compose_to(address.into());
|
||||
for (key, value) in parsed.query_pairs() {
|
||||
match key.as_ref() {
|
||||
"subject" => app.set_compose_subject(value.into_owned().into()),
|
||||
"cc" => app.set_compose_cc(value.into_owned().into()),
|
||||
"bcc" => app.set_compose_bcc(value.into_owned().into()),
|
||||
"body" => {
|
||||
app.invoke_edit_compose_body(value.into_owned().into(), 0, 0, "".into())
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if flectar_mail_core::mailto::is_mailto(&url) {
|
||||
crate::mailto_links::open(&app, [url.as_str()]);
|
||||
return;
|
||||
}
|
||||
if let Err(error) = open_email_link(&opener, &url) {
|
||||
|
||||
+147
-42
@@ -3,27 +3,31 @@
|
||||
//! The first process owns the profile and listens on a private local endpoint:
|
||||
//! a Unix socket guarded by an advisory lock, or a named pipe whose first
|
||||
//! instance is exclusive on Windows. A later launch asks that process to show
|
||||
//! its window and exits, so opening Flectar Mail from a launcher while it runs
|
||||
//! in the tray restores the existing window instead of starting a second sync
|
||||
//! engine on the same database. A sign-in launch that finds the application
|
||||
//! already running exits without disturbing it.
|
||||
//! its window, passing on any mail links it was opened with, and exits. So
|
||||
//! opening Flectar Mail from a launcher or a `mailto:` link while it runs in
|
||||
//! the tray uses the existing window instead of starting a second sync engine
|
||||
//! on the same database. A sign-in launch that finds the application already
|
||||
//! running exits without disturbing it.
|
||||
//!
|
||||
//! Both the lock and the pipe are released by the kernel when the owner exits,
|
||||
//! even after a crash, so a stale endpoint never blocks a later launch.
|
||||
|
||||
use crate::mailto_links::MAX_LAUNCH_LINKS;
|
||||
use flectar_mail_core::mailto;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{io, path::Path, sync::Arc, time::Duration};
|
||||
use tokio::{io::AsyncReadExt, runtime::Runtime, task::JoinHandle};
|
||||
|
||||
/// The only request: show and focus the main window, optionally followed by
|
||||
/// The only request: show and focus the main window. The first line may add
|
||||
/// the launcher's activation token so the compositor lets the window take
|
||||
/// focus.
|
||||
/// focus; each further line is a mail link to open.
|
||||
const SHOW: &str = "show";
|
||||
/// Activation tokens are short opaque strings; bound what we accept.
|
||||
const MAX_TOKEN: usize = 512;
|
||||
/// Longer than any valid request, so a misbehaving client cannot make the
|
||||
/// owner buffer more.
|
||||
const MAX_REQUEST: u64 = (SHOW.len() + 1 + MAX_TOKEN + 1) as u64;
|
||||
const MAX_REQUEST: u64 =
|
||||
(SHOW.len() + 1 + MAX_TOKEN + 1 + MAX_LAUNCH_LINKS * (mailto::MAX_URI_BYTES + 1)) as u64;
|
||||
/// How long the owner waits for a connected client to send its request.
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
/// How long a later launch waits for a starting owner to accept requests.
|
||||
@@ -43,14 +47,23 @@ pub(crate) enum Acquired {
|
||||
/// What a launch should ask of an already running instance.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub(crate) enum Request {
|
||||
/// Show the window, passing on the launcher's activation token.
|
||||
Show { activation_token: Option<String> },
|
||||
/// Show the window and open the mail links.
|
||||
Show(Activation),
|
||||
/// A sign-in launch: leave a running instance exactly as it is.
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// Runs on a runtime worker thread with the forwarded activation token.
|
||||
type ShowCallback = Arc<dyn Fn(Option<String>) + Send + Sync>;
|
||||
/// A launch handed to the running instance.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct Activation {
|
||||
/// The launcher's token, for the compositor to let the window take focus.
|
||||
pub(crate) activation_token: Option<String>,
|
||||
/// `mailto:` links the launch was opened with.
|
||||
pub(crate) links: Vec<String>,
|
||||
}
|
||||
|
||||
/// Runs on a runtime worker thread with each forwarded launch.
|
||||
type ShowCallback = Arc<dyn Fn(Activation) + Send + Sync>;
|
||||
|
||||
pub(crate) struct Primary {
|
||||
endpoint: imp::Endpoint,
|
||||
@@ -58,13 +71,13 @@ pub(crate) struct Primary {
|
||||
}
|
||||
|
||||
impl Primary {
|
||||
/// Start answering later launches. `show` receives the forwarded
|
||||
/// activation token on a runtime worker thread and must forward to the
|
||||
/// UI thread itself.
|
||||
/// Start answering later launches. `show` receives each forwarded
|
||||
/// launch on a runtime worker thread and must forward to the UI thread
|
||||
/// itself.
|
||||
pub(crate) fn serve(
|
||||
&mut self,
|
||||
runtime: &Runtime,
|
||||
show: impl Fn(Option<String>) + Send + Sync + 'static,
|
||||
show: impl Fn(Activation) + Send + Sync + 'static,
|
||||
) {
|
||||
let show: ShowCallback = Arc::new(show);
|
||||
let _context = runtime.enter();
|
||||
@@ -102,10 +115,10 @@ pub(crate) fn acquire(
|
||||
server: None,
|
||||
}));
|
||||
}
|
||||
let Request::Show { activation_token } = &request else {
|
||||
let Request::Show(activation) = &request else {
|
||||
return Ok(Acquired::Forwarded);
|
||||
};
|
||||
let message = encode_show(activation_token.as_deref());
|
||||
let message = encode_show(activation);
|
||||
match runtime.block_on(imp::forward(data_dir, &message)) {
|
||||
Ok(()) => return Ok(Acquired::Forwarded),
|
||||
// The owner may have exited after the claim failed; claim again.
|
||||
@@ -139,26 +152,58 @@ fn valid_token(token: &str) -> bool {
|
||||
(1..=MAX_TOKEN).contains(&token.len()) && token.bytes().all(|byte| byte.is_ascii_graphic())
|
||||
}
|
||||
|
||||
fn encode_show(activation_token: Option<&str>) -> Vec<u8> {
|
||||
match activation_token.filter(|token| valid_token(token)) {
|
||||
Some(token) => format!("{SHOW} {token}\n").into_bytes(),
|
||||
None => format!("{SHOW}\n").into_bytes(),
|
||||
fn encode_show(activation: &Activation) -> Vec<u8> {
|
||||
let mut request = match activation
|
||||
.activation_token
|
||||
.as_deref()
|
||||
.filter(|token| valid_token(token))
|
||||
{
|
||||
Some(token) => format!("{SHOW} {token}\n"),
|
||||
None => format!("{SHOW}\n"),
|
||||
};
|
||||
// Line breaks mean the same percent-encoded inside a mailto link, so a
|
||||
// link always fits on one line. Links the owner would refuse are dropped
|
||||
// here rather than failing the whole launch.
|
||||
let links = activation
|
||||
.links
|
||||
.iter()
|
||||
.map(|link| link.replace('\r', "%0D").replace('\n', "%0A"))
|
||||
.filter(|link| valid_link(link))
|
||||
.take(MAX_LAUNCH_LINKS);
|
||||
for link in links {
|
||||
request.push_str(&link);
|
||||
request.push('\n');
|
||||
}
|
||||
request.into_bytes()
|
||||
}
|
||||
|
||||
/// `Some(token)` for a well-formed show request; anything else is ignored.
|
||||
fn parse_show(request: &[u8]) -> Option<Option<String>> {
|
||||
let line = std::str::from_utf8(request).ok()?.strip_suffix('\n')?;
|
||||
match line.split_once(' ') {
|
||||
None => (line == SHOW).then_some(None),
|
||||
Some((SHOW, token)) => valid_token(token).then(|| Some(token.to_owned())),
|
||||
Some(_) => None,
|
||||
/// The launch for a well-formed show request; anything else is ignored.
|
||||
fn parse_show(request: &[u8]) -> Option<Activation> {
|
||||
let request = std::str::from_utf8(request).ok()?.strip_suffix('\n')?;
|
||||
let mut lines = request.split('\n');
|
||||
let first = lines.next()?;
|
||||
let activation_token = match first.split_once(' ') {
|
||||
None if first == SHOW => None,
|
||||
Some((SHOW, token)) if valid_token(token) => Some(token.to_owned()),
|
||||
_ => return None,
|
||||
};
|
||||
let links = lines.map(str::to_owned).collect::<Vec<_>>();
|
||||
if links.len() > MAX_LAUNCH_LINKS || !links.iter().all(|link| valid_link(link)) {
|
||||
return None;
|
||||
}
|
||||
Some(Activation {
|
||||
activation_token,
|
||||
links,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_request(request: Option<Vec<u8>>, show: &(dyn Fn(Option<String>) + Send + Sync)) {
|
||||
if let Some(activation_token) = request.as_deref().and_then(parse_show) {
|
||||
show(activation_token);
|
||||
fn valid_link(link: &str) -> bool {
|
||||
mailto::is_mailto(link) && link.len() <= mailto::MAX_URI_BYTES && !link.contains(['\r', '\n'])
|
||||
}
|
||||
|
||||
fn handle_request(request: Option<Vec<u8>>, show: &(dyn Fn(Activation) + Send + Sync)) {
|
||||
if let Some(activation) = request.as_deref().and_then(parse_show) {
|
||||
show(activation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,12 +466,17 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn show(activation_token: Option<&str>) -> Request {
|
||||
Request::Show {
|
||||
fn activation(activation_token: Option<&str>, links: &[&str]) -> Activation {
|
||||
Activation {
|
||||
activation_token: activation_token.map(str::to_owned),
|
||||
links: links.iter().map(|link| (*link).to_owned()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn show(activation_token: Option<&str>) -> Request {
|
||||
Request::Show(activation(activation_token, &[]))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_launches_reach_the_first_process_and_leave_it_running() {
|
||||
let runtime = runtime();
|
||||
@@ -437,15 +487,32 @@ mod tests {
|
||||
panic!("the first launch must own the profile");
|
||||
};
|
||||
let (shown_tx, shown_rx) = std::sync::mpsc::channel();
|
||||
primary.serve(&runtime, move |token| shown_tx.send(token).unwrap());
|
||||
primary.serve(&runtime, move |activation| {
|
||||
shown_tx.send(activation).unwrap()
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
acquire(&runtime, directory.path(), show(Some("launcher-token_1"))).unwrap(),
|
||||
Acquired::Forwarded
|
||||
));
|
||||
assert_eq!(
|
||||
shown_rx.recv_timeout(Duration::from_secs(5)).unwrap().as_deref(),
|
||||
Some("launcher-token_1")
|
||||
shown_rx.recv_timeout(Duration::from_secs(5)).unwrap(),
|
||||
activation(Some("launcher-token_1"), &[])
|
||||
);
|
||||
|
||||
// A mail link opened while the application runs reaches it whole.
|
||||
let link = format!(
|
||||
"mailto:a+b@example.com?subject=Hi&body={}%0D%0Aend",
|
||||
"x".repeat(100_000)
|
||||
);
|
||||
let request = Request::Show(activation(None, &[&link, "mailto:"]));
|
||||
assert!(matches!(
|
||||
acquire(&runtime, directory.path(), request).unwrap(),
|
||||
Acquired::Forwarded
|
||||
));
|
||||
assert_eq!(
|
||||
shown_rx.recv_timeout(Duration::from_secs(5)).unwrap(),
|
||||
activation(None, &[&link, "mailto:"])
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
@@ -482,14 +549,39 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn only_well_formed_show_requests_are_accepted() {
|
||||
assert_eq!(parse_show(&encode_show(None)), Some(None));
|
||||
let round_trip = |activation: Activation| parse_show(&encode_show(&activation));
|
||||
assert_eq!(
|
||||
parse_show(&encode_show(Some("abc:123"))),
|
||||
Some(Some("abc:123".to_owned()))
|
||||
round_trip(activation(None, &[])),
|
||||
Some(activation(None, &[]))
|
||||
);
|
||||
assert_eq!(
|
||||
round_trip(activation(Some("abc:123"), &["mailto:a@example.com"])),
|
||||
Some(activation(Some("abc:123"), &["mailto:a@example.com"]))
|
||||
);
|
||||
// Invalid tokens are dropped by the sender rather than the request.
|
||||
assert_eq!(parse_show(&encode_show(Some("has space"))), Some(None));
|
||||
assert_eq!(parse_show(&encode_show(Some(&"x".repeat(MAX_TOKEN + 1)))), Some(None));
|
||||
assert_eq!(
|
||||
round_trip(activation(Some("has space"), &[])),
|
||||
Some(activation(None, &[]))
|
||||
);
|
||||
assert_eq!(
|
||||
round_trip(activation(Some(&"x".repeat(MAX_TOKEN + 1)), &[])),
|
||||
Some(activation(None, &[]))
|
||||
);
|
||||
// So are links the owner would refuse, and raw line breaks are
|
||||
// encoded the way the link means them.
|
||||
let too_long = format!("mailto:?body={}", "x".repeat(mailto::MAX_URI_BYTES));
|
||||
assert_eq!(
|
||||
round_trip(activation(
|
||||
None,
|
||||
&["https://example.com", &too_long, "mailto:?body=a\r\nb"]
|
||||
)),
|
||||
Some(activation(None, &["mailto:?body=a%0D%0Ab"]))
|
||||
);
|
||||
let many = vec!["mailto:a@example.com"; MAX_LAUNCH_LINKS + 3];
|
||||
assert_eq!(
|
||||
round_trip(activation(None, &many)).unwrap().links.len(),
|
||||
MAX_LAUNCH_LINKS
|
||||
);
|
||||
|
||||
for request in [
|
||||
&b"quit\n"[..],
|
||||
@@ -499,9 +591,22 @@ mod tests {
|
||||
b"show a b\n",
|
||||
b"shows\n",
|
||||
b"show \xff\n",
|
||||
b"show\nhttps://example.com\n",
|
||||
b"show\nmailto:a@example.com",
|
||||
b"show\nmailto:a@example.com\n\n",
|
||||
b"mailto:a@example.com\n",
|
||||
] {
|
||||
assert_eq!(parse_show(request), None, "{request:?}");
|
||||
}
|
||||
assert!(MAX_REQUEST as usize >= encode_show(Some(&"x".repeat(MAX_TOKEN))).len());
|
||||
let largest = activation(
|
||||
Some(&"x".repeat(MAX_TOKEN)),
|
||||
&vec![
|
||||
&*format!("mailto:?body={}", "x".repeat(mailto::MAX_URI_BYTES - 13));
|
||||
MAX_LAUNCH_LINKS
|
||||
],
|
||||
);
|
||||
assert_eq!(largest.links[0].len(), mailto::MAX_URI_BYTES);
|
||||
assert!(MAX_REQUEST as usize >= encode_show(&largest).len());
|
||||
assert_eq!(round_trip(largest.clone()), Some(largest));
|
||||
}
|
||||
}
|
||||
@@ -302,6 +302,10 @@ export global I18n {
|
||||
ui-message-system(key, argument, second-argument, third-argument) => {
|
||||
if (key == "Opened {}") { return @tr("Opened {}", argument); }
|
||||
if (key == "Could not open link: {}") { return @tr("Could not open link: {}", argument); }
|
||||
if (key == "This email link is too long to open.") { return @tr("This email link is too long to open."); }
|
||||
if (key == "The email link will open when you close this message.") { return @tr("The email link will open when you close this message."); }
|
||||
if (key == "The message you were writing was saved to Drafts.") { return @tr("The message you were writing was saved to Drafts."); }
|
||||
if (key == "Files from links are never attached for you. Attach them yourself if you trust them: {}") { return @tr("Files from links are never attached for you. Attach them yourself if you trust them: {}", argument); }
|
||||
if (key == "Copied HTML source") { return @tr("Copied HTML source"); }
|
||||
if (key == "Copied selected text") { return @tr("Copied selected text"); }
|
||||
if (key == "Copied email text") { return @tr("Copied email text"); }
|
||||
|
||||
@@ -295,6 +295,10 @@ export component ComposerRecipients inherits Rectangle {
|
||||
callback contact-selected(int);
|
||||
private property <bool> show-cc: root.cc != "";
|
||||
private property <bool> show-bcc: root.bcc != "";
|
||||
// Recipients filled in for the user, such as from a mail link, always
|
||||
// show, even in a row that was toggled off.
|
||||
changed cc => { if (root.cc != "") { root.show-cc = true; } }
|
||||
changed bcc => { if (root.bcc != "") { root.show-bcc = true; } }
|
||||
private property <bool> suggestions-dismissed: false;
|
||||
// Suggestion rows hold a name and an address that grow with the platform font.
|
||||
private property <length> suggestion-pitch: 13px + max(18px, suggestion-name-probe.preferred-height)
|
||||
|
||||
Reference in new issue
Block a user