Add macOS URL scheme handling
This commit is contained in:
@@ -47,6 +47,15 @@ jobs:
|
|||||||
- name: Audit source file size
|
- name: Audit source file size
|
||||||
run: scripts/audit_source_lines.sh
|
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:
|
cloudflare:
|
||||||
name: Cloudflare worker
|
name: Cloudflare worker
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
+193
-9
@@ -2,9 +2,17 @@ mod services;
|
|||||||
mod shell;
|
mod shell;
|
||||||
mod shortcuts;
|
mod shortcuts;
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
cell::RefCell,
|
||||||
|
rc::Rc,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use ely_domain::UrlText;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Application, Bounds, Focusable, Menu, MenuItem, SystemMenuType,
|
AnyWindowHandle, App, AppContext, Application, Bounds, Entity, Focusable, Menu, MenuItem,
|
||||||
TitlebarOptions, WindowBounds, WindowOptions, actions, point, px, size,
|
SystemMenuType, Timer, TitlebarOptions, WindowBounds, WindowOptions, actions, point, px, size,
|
||||||
};
|
};
|
||||||
use gpui_component_assets::Assets;
|
use gpui_component_assets::Assets;
|
||||||
use shell::ElyShell;
|
use shell::ElyShell;
|
||||||
@@ -36,7 +44,12 @@ actions!(
|
|||||||
);
|
);
|
||||||
|
|
||||||
fn main() {
|
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);
|
gpui_component::init(cx);
|
||||||
cx.on_action(quit);
|
cx.on_action(quit);
|
||||||
bind_shortcuts(cx);
|
bind_shortcuts(cx);
|
||||||
@@ -81,14 +94,43 @@ fn main() {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if open_browser_window(cx, BrowserWindowMode::Standard) {
|
let current_window =
|
||||||
cx.activate(true);
|
Rc::new(RefCell::new(open_browser_window(cx, BrowserWindowMode::Standard)));
|
||||||
} else {
|
|
||||||
|
if current_window.borrow().is_none() {
|
||||||
cx.quit();
|
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)]
|
#[derive(Clone, Copy)]
|
||||||
enum BrowserWindowMode {
|
enum BrowserWindowMode {
|
||||||
Standard,
|
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<BrowserWindowTarget> {
|
||||||
let bounds = Bounds::centered(None, size(px(1240.0), px(780.0)), cx);
|
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(
|
let opened = cx.open_window(
|
||||||
WindowOptions {
|
WindowOptions {
|
||||||
titlebar: Some(TitlebarOptions {
|
titlebar: Some(TitlebarOptions {
|
||||||
@@ -121,6 +165,8 @@ fn open_browser_window(cx: &mut App, mode: BrowserWindowMode) -> bool {
|
|||||||
BrowserWindowMode::Standard => ElyShell::new(window, cx),
|
BrowserWindowMode::Standard => ElyShell::new(window, cx),
|
||||||
BrowserWindowMode::Private => ElyShell::new_private(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);
|
let focus_handle = shell.focus_handle(cx);
|
||||||
window.defer(cx, move |window, cx| {
|
window.defer(cx, move |window, cx| {
|
||||||
if window.focused(cx).is_none() {
|
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<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_ely_deep_link(&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(..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) {
|
fn quit(_: &Quit, cx: &mut App) {
|
||||||
@@ -139,7 +286,44 @@ fn quit(_: &Quit, cx: &mut App) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn open_private_window(_: &OpenPrivateWindow, 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);
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ impl ElyShell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn open_url(&mut self, url: UrlText, window: &mut Window, cx: &mut Context<Self>) {
|
pub(crate) fn open_url(&mut self, url: UrlText, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if let ShellState::Ready(core) = &mut self.state {
|
if let ShellState::Ready(core) = &mut self.state {
|
||||||
core.open_tab(url);
|
core.open_tab(url);
|
||||||
self.sync_address_input(window, cx);
|
self.sync_address_input(window, cx);
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
<string>ely_app</string>
|
<string>ely_app</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>com.elydora.ely-browser</string>
|
<string>com.elydora.ely-browser</string>
|
||||||
|
<key>CFBundleGetInfoString</key>
|
||||||
|
<string>ELY Browser by Elydora</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
@@ -21,6 +23,17 @@
|
|||||||
<string>0.1.0</string>
|
<string>0.1.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>1</string>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.elydora.ely-browser</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>ely</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
<key>LSApplicationCategoryType</key>
|
<key>LSApplicationCategoryType</key>
|
||||||
<string>public.app-category.productivity</string>
|
<string>public.app-category.productivity</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
@@ -29,5 +42,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>Copyright 2026 Elydora. All rights reserved.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
Executable
+49
@@ -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"
|
||||||
Reference in New Issue
Block a user