435 lines
14 KiB
Rust
435 lines
14 KiB
Rust
mod brand;
|
|
mod services;
|
|
mod shell;
|
|
mod shortcuts;
|
|
|
|
use std::{
|
|
cell::RefCell,
|
|
env,
|
|
rc::Rc,
|
|
sync::{Arc, Mutex},
|
|
time::Duration,
|
|
};
|
|
|
|
use ely_domain::UrlText;
|
|
use gpui::{
|
|
AnyWindowHandle, App, AppContext, Application, Bounds, Entity, Focusable, Menu, MenuItem,
|
|
SystemMenuType, Timer, TitlebarOptions, WindowBounds, WindowOptions, actions, point, px, size,
|
|
};
|
|
use gpui_component_assets::Assets;
|
|
use shell::ElyShell;
|
|
use shell::chrome::{TRAFFIC_LIGHT_ORIGIN_X, TRAFFIC_LIGHT_ORIGIN_Y};
|
|
use shortcuts::bind_shortcuts;
|
|
use url::Url;
|
|
|
|
use crate::brand::{DEEP_LINK_PREFIX, PRODUCT_NAME};
|
|
|
|
actions!(
|
|
ely_app,
|
|
[
|
|
CloseCurrentTab,
|
|
DownloadCurrentPage,
|
|
FocusAddressBar,
|
|
FocusCommandMode,
|
|
OpenDownloads,
|
|
OpenHistory,
|
|
OpenNewTab,
|
|
OpenPrivateWindow,
|
|
OpenSettings,
|
|
OpenTaskManager,
|
|
Quit,
|
|
ReloadTab,
|
|
RestoreClosedTab,
|
|
ResetZoom,
|
|
SelectNextSpace,
|
|
SelectNextTab,
|
|
SelectPreviousSpace,
|
|
SelectPreviousTab,
|
|
SplitRight,
|
|
ToggleFavoriteTab,
|
|
TogglePinnedTab,
|
|
ToggleSidebar,
|
|
ZoomIn,
|
|
ZoomOut,
|
|
]
|
|
);
|
|
|
|
fn main() {
|
|
init_tracing();
|
|
let pending_deep_links = PendingDeepLinks::default();
|
|
pending_deep_links.push(startup_open_urls(env::args().skip(1)));
|
|
let open_url_queue = pending_deep_links.clone();
|
|
let application = Application::new().with_assets(Assets);
|
|
|
|
application.on_open_urls(move |urls| open_url_queue.push(urls));
|
|
application.run(move |cx: &mut App| {
|
|
gpui_component::init(cx);
|
|
if let Err(error) = shell::chrome::register_serif_fonts(cx) {
|
|
eprintln!("ELY: failed to register serif fonts: {error}");
|
|
}
|
|
// gpui-component's Input, tooltip, context menu, search popover,
|
|
// notification surface, etc. all read their font from
|
|
// `cx.theme().font_family` directly instead of inheriting from
|
|
// the parent. Default theme value is `.SystemUIFont` (SF Pro on
|
|
// macOS), so without this override the user types into the
|
|
// omnibar in SF Pro while every other surface renders Geist —
|
|
// exactly the "fonts still wrong" the screenshot showed.
|
|
// Override the theme so every gpui-component sub-element uses
|
|
// Geist too.
|
|
gpui_component::Theme::global_mut(cx).font_family = shell::chrome::SANS_FAMILY.into();
|
|
cx.on_action(quit);
|
|
bind_shortcuts(cx);
|
|
cx.on_action(open_private_window);
|
|
cx.set_menus(vec![
|
|
Menu {
|
|
name: PRODUCT_NAME.into(),
|
|
items: vec![
|
|
MenuItem::os_submenu("Services", SystemMenuType::Services),
|
|
MenuItem::separator(),
|
|
MenuItem::action(format!("Quit {PRODUCT_NAME}"), Quit),
|
|
],
|
|
},
|
|
Menu {
|
|
name: "File".into(),
|
|
items: vec![
|
|
MenuItem::action("New Tab", OpenNewTab),
|
|
MenuItem::action("New Private Window", OpenPrivateWindow),
|
|
MenuItem::action("Split Right", SplitRight),
|
|
MenuItem::separator(),
|
|
MenuItem::action("Command Mode", FocusCommandMode),
|
|
MenuItem::action("Toggle Sidebar", ToggleSidebar),
|
|
MenuItem::separator(),
|
|
MenuItem::action("Download Current Page", DownloadCurrentPage),
|
|
MenuItem::action("Open Downloads", OpenDownloads),
|
|
MenuItem::separator(),
|
|
MenuItem::action("Zoom In", ZoomIn),
|
|
MenuItem::action("Zoom Out", ZoomOut),
|
|
MenuItem::action("Reset Zoom", ResetZoom),
|
|
MenuItem::separator(),
|
|
MenuItem::action("Reload Tab", ReloadTab),
|
|
MenuItem::action("Close Tab", CloseCurrentTab),
|
|
MenuItem::separator(),
|
|
MenuItem::action("Restore Closed Tab", RestoreClosedTab),
|
|
MenuItem::separator(),
|
|
MenuItem::action("Next Space", SelectNextSpace),
|
|
MenuItem::action("Previous Space", SelectPreviousSpace),
|
|
MenuItem::separator(),
|
|
MenuItem::action("Open History", OpenHistory),
|
|
MenuItem::action("Open Task Manager", OpenTaskManager),
|
|
MenuItem::action("Open Settings", OpenSettings),
|
|
MenuItem::separator(),
|
|
MenuItem::action("Toggle Pin", TogglePinnedTab),
|
|
],
|
|
},
|
|
Menu {
|
|
name: "Bookmarks".into(),
|
|
items: vec![MenuItem::action("Toggle Favorite", ToggleFavoriteTab)],
|
|
},
|
|
]);
|
|
|
|
let current_window =
|
|
Rc::new(RefCell::new(open_browser_window(cx, BrowserWindowMode::Standard)));
|
|
|
|
if current_window.borrow().is_none() {
|
|
cx.quit();
|
|
return;
|
|
}
|
|
|
|
route_pending_deep_links(&pending_deep_links, ¤t_window, cx);
|
|
watch_deep_links(pending_deep_links, current_window, cx);
|
|
cx.activate(true);
|
|
});
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct BrowserWindowTarget {
|
|
window: AnyWindowHandle,
|
|
shell: Entity<ElyShell>,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct PendingDeepLinks {
|
|
urls: Arc<Mutex<Vec<String>>>,
|
|
}
|
|
|
|
impl PendingDeepLinks {
|
|
fn push(&self, incoming_urls: Vec<String>) {
|
|
if let Ok(mut urls) = self.urls.lock() {
|
|
urls.extend(incoming_urls);
|
|
}
|
|
}
|
|
|
|
fn drain(&self) -> Vec<String> {
|
|
self.urls.lock().map(|mut urls| urls.drain(..).collect()).unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum BrowserWindowMode {
|
|
Standard,
|
|
Private,
|
|
}
|
|
|
|
impl BrowserWindowMode {
|
|
fn title(self) -> &'static str {
|
|
match self {
|
|
Self::Standard => PRODUCT_NAME,
|
|
Self::Private => "ELY Browser - Private",
|
|
}
|
|
}
|
|
}
|
|
|
|
fn open_browser_window(cx: &mut App, mode: BrowserWindowMode) -> Option<BrowserWindowTarget> {
|
|
let bounds = Bounds::centered(None, size(px(1240.0), px(780.0)), cx);
|
|
let target = Rc::new(RefCell::new(None));
|
|
let created_target = target.clone();
|
|
let opened = cx.open_window(
|
|
WindowOptions {
|
|
titlebar: Some(TitlebarOptions {
|
|
title: Some(mode.title().into()),
|
|
appears_transparent: true,
|
|
traffic_light_position: Some(point(
|
|
px(TRAFFIC_LIGHT_ORIGIN_X),
|
|
px(TRAFFIC_LIGHT_ORIGIN_Y),
|
|
)),
|
|
}),
|
|
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
|
..WindowOptions::default()
|
|
},
|
|
|window, cx| {
|
|
let shell = cx.new(|cx| match mode {
|
|
BrowserWindowMode::Standard => ElyShell::new(window, cx),
|
|
BrowserWindowMode::Private => ElyShell::new_private(window, cx),
|
|
});
|
|
*created_target.borrow_mut() =
|
|
Some(BrowserWindowTarget { window: window.window_handle(), shell: shell.clone() });
|
|
let focus_handle = shell.focus_handle(cx);
|
|
window.defer(cx, move |window, cx| {
|
|
if window.focused(cx).is_none() {
|
|
focus_handle.focus(window);
|
|
}
|
|
});
|
|
cx.new(|cx| gpui_component::Root::new(shell, window, cx))
|
|
},
|
|
);
|
|
|
|
opened.ok()?;
|
|
target.borrow().clone()
|
|
}
|
|
|
|
fn route_pending_deep_links(
|
|
pending_deep_links: &PendingDeepLinks,
|
|
current_window: &Rc<RefCell<Option<BrowserWindowTarget>>>,
|
|
cx: &mut App,
|
|
) {
|
|
let urls = pending_deep_links.drain();
|
|
open_deep_links(urls, current_window, cx);
|
|
}
|
|
|
|
fn watch_deep_links(
|
|
pending_deep_links: PendingDeepLinks,
|
|
current_window: Rc<RefCell<Option<BrowserWindowTarget>>>,
|
|
cx: &mut App,
|
|
) {
|
|
cx.spawn(async move |cx| {
|
|
loop {
|
|
Timer::after(Duration::from_millis(150)).await;
|
|
let urls = pending_deep_links.drain();
|
|
|
|
if urls.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let _ = cx.update(|cx| open_deep_links(urls, ¤t_window, cx));
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
fn open_deep_links(
|
|
urls: Vec<String>,
|
|
current_window: &Rc<RefCell<Option<BrowserWindowTarget>>>,
|
|
cx: &mut App,
|
|
) {
|
|
let urls = urls.into_iter().filter_map(|url| parse_open_url(&url)).collect::<Vec<_>>();
|
|
|
|
if urls.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let Some(mut target) = ensure_browser_window_target(current_window, cx) else {
|
|
return;
|
|
};
|
|
|
|
let mut opened_any = false;
|
|
for url in urls {
|
|
if open_deep_link(&target, url.clone(), cx) {
|
|
opened_any = true;
|
|
continue;
|
|
}
|
|
|
|
current_window.borrow_mut().take();
|
|
let Some(next_target) = ensure_browser_window_target(current_window, cx) else {
|
|
return;
|
|
};
|
|
target = next_target;
|
|
|
|
if open_deep_link(&target, url, cx) {
|
|
opened_any = true;
|
|
}
|
|
}
|
|
|
|
if opened_any {
|
|
cx.activate(true);
|
|
}
|
|
}
|
|
|
|
fn ensure_browser_window_target(
|
|
current_window: &Rc<RefCell<Option<BrowserWindowTarget>>>,
|
|
cx: &mut App,
|
|
) -> Option<BrowserWindowTarget> {
|
|
if let Some(target) = current_window.borrow().clone() {
|
|
return Some(target);
|
|
}
|
|
|
|
let target = open_browser_window(cx, BrowserWindowMode::Standard)?;
|
|
current_window.borrow_mut().replace(target.clone());
|
|
Some(target)
|
|
}
|
|
|
|
fn open_deep_link(target: &BrowserWindowTarget, url: UrlText, cx: &mut App) -> bool {
|
|
target
|
|
.window
|
|
.update(cx, |_, window, cx| {
|
|
target.shell.update(cx, |shell, cx| shell.open_url(url, window, cx));
|
|
})
|
|
.is_ok()
|
|
}
|
|
|
|
fn parse_ely_deep_link(value: &str) -> Option<UrlText> {
|
|
let trimmed = value.trim();
|
|
let scheme = trimmed.get(..DEEP_LINK_PREFIX.len())?;
|
|
let route = trimmed.get(DEEP_LINK_PREFIX.len()..)?;
|
|
|
|
scheme
|
|
.eq_ignore_ascii_case(DEEP_LINK_PREFIX)
|
|
.then(|| UrlText::parse(format!("{DEEP_LINK_PREFIX}{route}")).ok())
|
|
.flatten()
|
|
}
|
|
|
|
fn parse_open_url(value: &str) -> Option<UrlText> {
|
|
parse_ely_deep_link(value).or_else(|| parse_external_open_url(value))
|
|
}
|
|
|
|
fn parse_external_open_url(value: &str) -> Option<UrlText> {
|
|
let parsed = UrlText::from_address_text(value).ok()?;
|
|
let url = Url::parse(parsed.as_str()).ok()?;
|
|
matches!(url.scheme(), "http" | "https").then(|| UrlText::parse(url.to_string()).ok()).flatten()
|
|
}
|
|
|
|
fn startup_open_urls(args: impl IntoIterator<Item = String>) -> Vec<String> {
|
|
args.into_iter()
|
|
.filter_map(|arg| parse_open_url(&arg).map(|url| url.as_str().to_string()))
|
|
.collect()
|
|
}
|
|
|
|
fn quit(_: &Quit, cx: &mut App) {
|
|
cx.quit();
|
|
}
|
|
|
|
const DEFAULT_TRACING_FILTER: &str = "ely_app=info,ely_servo_host=warn,ely=info";
|
|
|
|
/// Install the global tracing subscriber. `RUST_LOG` drives the
|
|
/// filter; absent it, app-owned targets stay visible and Servo internals
|
|
/// stay opt-in. `RUST_LOG=ely::servo::perf=info` flips on the frame-time
|
|
/// stream. We swallow re-init errors so tests that share process state
|
|
/// with main can reuse this path.
|
|
fn init_tracing() {
|
|
use tracing_subscriber::{EnvFilter, fmt};
|
|
let filter = EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| EnvFilter::new(DEFAULT_TRACING_FILTER));
|
|
let _ = fmt().with_env_filter(filter).with_target(true).try_init();
|
|
}
|
|
|
|
fn open_private_window(_: &OpenPrivateWindow, cx: &mut App) {
|
|
if open_browser_window(cx, BrowserWindowMode::Private).is_some() {
|
|
cx.activate(true);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{PendingDeepLinks, parse_ely_deep_link, parse_open_url, startup_open_urls};
|
|
|
|
#[test]
|
|
fn pending_deep_links_drains_urls_in_order() {
|
|
let pending = PendingDeepLinks::default();
|
|
|
|
pending.push(vec!["ely://history".into(), "ely://settings".into()]);
|
|
|
|
assert_eq!(
|
|
pending.drain(),
|
|
vec!["ely://history".to_string(), "ely://settings".to_string()]
|
|
);
|
|
assert!(pending.drain().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_ely_deep_link_accepts_internal_routes() {
|
|
let url = parse_ely_deep_link(" ely://auth/callback?code=abc ");
|
|
|
|
assert_eq!(url.as_ref().map(|url| url.as_str()), Some("ely://auth/callback?code=abc"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_ely_deep_link_normalizes_scheme_case() {
|
|
let url = parse_ely_deep_link("ELY://history");
|
|
|
|
assert_eq!(url.as_ref().map(|url| url.as_str()), Some("ely://history"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_ely_deep_link_filters_other_schemes() {
|
|
assert!(parse_ely_deep_link("https://example.com").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_open_url_accepts_external_pages() {
|
|
let url = parse_open_url(" HTTPS://example.com ");
|
|
|
|
assert_eq!(url.as_ref().map(|url| url.as_str()), Some("https://example.com/"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_open_url_accepts_address_text_domains() {
|
|
let url = parse_open_url("servo.org");
|
|
|
|
assert_eq!(url.as_ref().map(|url| url.as_str()), Some("https://servo.org/"));
|
|
}
|
|
|
|
#[test]
|
|
fn startup_open_urls_filter_and_normalize_args() {
|
|
let links = startup_open_urls(
|
|
[
|
|
"--ignored",
|
|
" ELY://history ",
|
|
"https://example.com",
|
|
"servo.org",
|
|
"ely://auth/callback?code=abc",
|
|
]
|
|
.into_iter()
|
|
.map(str::to_string),
|
|
);
|
|
|
|
assert_eq!(
|
|
links,
|
|
vec![
|
|
"ely://history".to_string(),
|
|
"https://example.com/".to_string(),
|
|
"https://servo.org/".to_string(),
|
|
"ely://auth/callback?code=abc".to_string(),
|
|
]
|
|
);
|
|
}
|
|
}
|