diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8363b01..4214284 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,15 @@ jobs: - name: Audit source file size run: scripts/audit_source_lines.sh + - name: Verify macOS bundle metadata + run: scripts/verify_macos_bundle_metadata.sh + + - name: Build macOS app bundle + run: scripts/create_macos_app_bundle.sh + + - name: Verify built macOS app bundle metadata + run: scripts/verify_macos_bundle_metadata.sh "target/macos/ELY Browser.app/Contents/Info.plist" + cloudflare: name: Cloudflare worker runs-on: ubuntu-latest diff --git a/crates/ely_app/src/main.rs b/crates/ely_app/src/main.rs index 6fe0926..3715ca6 100644 --- a/crates/ely_app/src/main.rs +++ b/crates/ely_app/src/main.rs @@ -2,9 +2,17 @@ mod services; mod shell; mod shortcuts; +use std::{ + cell::RefCell, + rc::Rc, + sync::{Arc, Mutex}, + time::Duration, +}; + +use ely_domain::UrlText; use gpui::{ - App, AppContext, Application, Bounds, Focusable, Menu, MenuItem, SystemMenuType, - TitlebarOptions, WindowBounds, WindowOptions, actions, point, px, size, + 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; @@ -36,7 +44,12 @@ actions!( ); fn main() { - Application::new().with_assets(Assets).run(|cx: &mut App| { + let pending_deep_links = PendingDeepLinks::default(); + 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); cx.on_action(quit); bind_shortcuts(cx); @@ -81,14 +94,43 @@ fn main() { }, ]); - if open_browser_window(cx, BrowserWindowMode::Standard) { - cx.activate(true); - } else { + 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, +} + +#[derive(Clone, Default)] +struct PendingDeepLinks { + urls: Arc>>, +} + +impl PendingDeepLinks { + fn push(&self, incoming_urls: Vec) { + if let Ok(mut urls) = self.urls.lock() { + urls.extend(incoming_urls); + } + } + + fn drain(&self) -> Vec { + self.urls.lock().map(|mut urls| urls.drain(..).collect()).unwrap_or_default() + } +} + #[derive(Clone, Copy)] enum BrowserWindowMode { Standard, @@ -104,8 +146,10 @@ impl BrowserWindowMode { } } -fn open_browser_window(cx: &mut App, mode: BrowserWindowMode) -> bool { +fn open_browser_window(cx: &mut App, mode: BrowserWindowMode) -> Option { 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 { @@ -121,6 +165,8 @@ fn open_browser_window(cx: &mut App, mode: BrowserWindowMode) -> bool { 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() { @@ -131,7 +177,108 @@ fn open_browser_window(cx: &mut App, mode: BrowserWindowMode) -> bool { }, ); - opened.is_ok() + opened.ok()?; + target.borrow().clone() +} + +fn route_pending_deep_links( + pending_deep_links: &PendingDeepLinks, + current_window: &Rc>>, + 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>>, + 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, + current_window: &Rc>>, + cx: &mut App, +) { + let urls = urls.into_iter().filter_map(|url| parse_ely_deep_link(&url)).collect::>(); + + 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>>, + cx: &mut App, +) -> Option { + 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 { + let trimmed = value.trim(); + let scheme = trimmed.get(..6)?; + let route = trimmed.get(6..)?; + + scheme + .eq_ignore_ascii_case("ely://") + .then(|| UrlText::parse(format!("ely://{route}")).ok()) + .flatten() } fn quit(_: &Quit, cx: &mut App) { @@ -139,7 +286,44 @@ fn quit(_: &Quit, cx: &mut App) { } fn open_private_window(_: &OpenPrivateWindow, cx: &mut App) { - if open_browser_window(cx, BrowserWindowMode::Private) { + if open_browser_window(cx, BrowserWindowMode::Private).is_some() { cx.activate(true); } } + +#[cfg(test)] +mod tests { + use super::{PendingDeepLinks, parse_ely_deep_link}; + + #[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()); + } +} diff --git a/crates/ely_app/src/shell/navigation.rs b/crates/ely_app/src/shell/navigation.rs index a8d7559..2aba629 100644 --- a/crates/ely_app/src/shell/navigation.rs +++ b/crates/ely_app/src/shell/navigation.rs @@ -49,7 +49,7 @@ impl ElyShell { } } - pub(super) fn open_url(&mut self, url: UrlText, window: &mut Window, cx: &mut Context) { + pub(crate) fn open_url(&mut self, url: UrlText, window: &mut Window, cx: &mut Context) { if let ShellState::Ready(core) = &mut self.state { core.open_tab(url); self.sync_address_input(window, cx); diff --git a/packaging/macos/Info.plist b/packaging/macos/Info.plist index 62887cb..5c735af 100644 --- a/packaging/macos/Info.plist +++ b/packaging/macos/Info.plist @@ -11,6 +11,8 @@ ely_app CFBundleIdentifier com.elydora.ely-browser + CFBundleGetInfoString + ELY Browser by Elydora CFBundleInfoDictionaryVersion 6.0 CFBundleName @@ -21,6 +23,17 @@ 0.1.0 CFBundleVersion 1 + CFBundleURLTypes + + + CFBundleURLName + com.elydora.ely-browser + CFBundleURLSchemes + + ely + + + LSApplicationCategoryType public.app-category.productivity LSMinimumSystemVersion @@ -29,5 +42,7 @@ NSSupportsAutomaticGraphicsSwitching + NSHumanReadableCopyright + Copyright 2026 Elydora. All rights reserved. diff --git a/scripts/verify_macos_bundle_metadata.sh b/scripts/verify_macos_bundle_metadata.sh new file mode 100755 index 0000000..2606514 --- /dev/null +++ b/scripts/verify_macos_bundle_metadata.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +plist_path="${1:-packaging/macos/Info.plist}" + +if [[ ! -f "${plist_path}" ]]; then + echo "${plist_path}: plist file is missing" + exit 1 +fi + +plutil -lint "${plist_path}" >/dev/null + +plist_value() { + /usr/libexec/PlistBuddy -c "Print :$1" "${plist_path}" +} + +assert_value() { + local key="$1" + local expected="$2" + local actual + actual="$(plist_value "${key}")" + if [[ "${actual}" != "${expected}" ]]; then + echo "${plist_path}: ${key} expected '${expected}', got '${actual}'" + exit 1 + fi +} + +assert_value "CFBundleDisplayName" "ELY Browser" +assert_value "CFBundleName" "ELY Browser" +assert_value "CFBundleIdentifier" "com.elydora.ely-browser" +assert_value "CFBundleGetInfoString" "ELY Browser by Elydora" +assert_value "CFBundleExecutable" "ely_app" +assert_value "CFBundlePackageType" "APPL" +assert_value "LSApplicationCategoryType" "public.app-category.productivity" + +url_name="$(/usr/libexec/PlistBuddy -c "Print :CFBundleURLTypes:0:CFBundleURLName" "${plist_path}")" +url_scheme="$(/usr/libexec/PlistBuddy -c "Print :CFBundleURLTypes:0:CFBundleURLSchemes:0" "${plist_path}")" + +if [[ "${url_name}" != "com.elydora.ely-browser" ]]; then + echo "${plist_path}: URL type name expected 'com.elydora.ely-browser', got '${url_name}'" + exit 1 +fi + +if [[ "${url_scheme}" != "ely" ]]; then + echo "${plist_path}: URL scheme expected 'ely', got '${url_scheme}'" + exit 1 +fi + +echo "${plist_path}: macOS bundle metadata ok"