diff --git a/README.md b/README.md index 9305826..9f2f72b 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ The shell adds only the desktop behavior that improves that workflow: - multiple Spark tabs in one native window; - additional Spark windows; - persistent Google session state; -- Google Docs and Sheets opened by Spark stay inside Spark Desktop tabs; +- Google Drive, Docs, and Sheets links opened by Spark hand off to Google Chrome for reliable Workspace behavior; - native frameless window controls and drag behavior; - keyboard-first tab controls; - dark/light application chrome; @@ -38,12 +38,11 @@ It intentionally does **not** add an address bar, bookmarks, browser history, ex flowchart LR A[Windows / Tauri shell] --> B[Gemini Spark tab] A --> C[Gemini Spark tab] - A --> D[Google Docs tab] - A --> E[Google Sheets tab] + B --> D[Google Chrome] + C --> D B --> F[Google-owned web experience] C --> F - D --> G[Google Workspace] - E --> G + D --> G[Google Drive / Docs / Sheets] ``` Spark Desktop owns the **desktop shell**. Google continues to own the **Gemini and Workspace web experiences, authentication, content, and service behavior**. @@ -53,10 +52,10 @@ Spark Desktop owns the **desktop shell**. Google continues to own the **Gemini a | Area | Behavior | |---|---| | Spark | Loads `https://gemini.google.com/spark` directly | -| Tabs | Multiple internal Spark / Workspace tabs | +| Tabs | Multiple internal Spark tabs | | Windows | Multiple native Spark Desktop windows | | Session | Shared persistent WebView2 application profile | -| Workspace | Docs / Sheets opened from Spark remain in-app | +| Workspace | Drive / Docs / Sheets links opened from Spark launch in Google Chrome | | Window chrome | Native minimize, maximize/restore, close, drag-to-move | | Shortcuts | `Ctrl+T`, `Ctrl+W`, `Ctrl+Tab`, `Ctrl+Shift+Tab`, `Ctrl+Shift+N`, `Ctrl+R` | | Updates | Signed GitHub Releases + Tauri automatic updater | @@ -80,16 +79,17 @@ Tauri 2 native window │ └─ 40px top bar: tabs + native window controls └─ Child WebView2 instances ├─ Gemini Spark - ├─ Gemini Spark - ├─ Google Docs - └─ Google Sheets + └─ Gemini Spark + +Google Chrome +└─ Google Drive / Docs / Sheets opened from Spark ``` Key decisions: - remote Gemini/Google content does not receive Spark Desktop native Tauri capabilities; - tabs share the application WebView2 data store for consistent Google session behavior; -- Workspace routing is deliberately narrow instead of becoming a general browser; +- Workspace routing is deliberately narrow and hands Drive, Docs, and Sheets to Chrome instead of emulating a general browser; - application releases are delivered through signed updater artifacts. See **[Architecture](docs/ARCHITECTURE.md)** for the implementation model. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 74e3cef..e4cdb56 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -10,9 +10,9 @@ It is intentionally not a general-purpose browser. - Tauri 2 native window. - React shell occupies only the 40px top bar. - Each tab is a child WebView2 webview below the top bar. -- Child tab webviews are created by the Rust host so native WebView2 new-window requests can be intercepted before Windows opens an external browser. +- Child tab webviews are created by the Rust host so native WebView2 new-window requests can be routed according to the trusted Spark/Workspace boundary. - Spark tabs intentionally use Tauri's default application WebView2 data store (no per-tab data-directory override), preserving a shared Google session across tabs and shell windows. -- Google Docs and Sheets links opened by Spark are routed into new internal tabs in the same Spark Desktop window instead of Chrome or another external browser. +- Google Drive, Docs, and Sheets links opened by Spark are intercepted by the Rust host and handed to Google Chrome. This avoids relying on unsupported Workspace behavior inside an embedded WebView2 surface. - New shell windows can host their own child webviews while sharing the same application session store. - Remote content webviews are excluded from Tauri capabilities; only bundled shell webviews receive native permissions. @@ -25,7 +25,7 @@ The frameless shell delegates minimize, maximize/restore, close, and drag operat - New Spark tab. - Close tab. - New window. -- Internal Google Docs / Sheets tabs opened from Spark. +- Explicit Google Drive / Docs / Sheets handoff to Google Chrome. - Native minimize / maximize / close controls. - Drag-to-move frameless window and double-click title-bar maximize/restore. - Dark/light shell theme. diff --git a/docs/GEMINI_SPARK_DESKTOP_FINDINGS.md b/docs/GEMINI_SPARK_DESKTOP_FINDINGS.md index 348c2df..8b80079 100644 --- a/docs/GEMINI_SPARK_DESKTOP_FINDINGS.md +++ b/docs/GEMINI_SPARK_DESKTOP_FINDINGS.md @@ -17,16 +17,16 @@ The most valuable shell responsibilities have been: - additional native windows; - keyboard-first tab management; - native window movement and controls; -- in-app routing for related Google Workspace documents; +- reliable Chrome handoff for related Google Workspace documents; - automatic signed desktop updates. The design becomes worse when it starts behaving like a full browser. An address bar, bookmarks, extensions, history UI, and unrelated navigation add complexity without improving the core Spark workflow. ## 2. Google Workspace continuity matters -Spark can produce or open Google Docs and Google Sheets as part of a workflow. Sending those documents to a separate browser interrupts the desktop context. +Spark can produce or open Google Drive files, Google Docs, and Google Sheets as part of a workflow. In practice, these Workspace surfaces are not consistently reliable inside the embedded WebView2 host. -Spark Desktop therefore treats Docs and Sheets opened from Spark as part of the same workspace and routes them into internal tabs. +Spark Desktop therefore keeps Spark itself in-app and hands Drive, Docs, and Sheets links to Google Chrome, where Google Workspace receives its expected browser environment. This exposed an important integration lesson: modern Google Workspace applications can rely on normal browser popup/window semantics during startup. A desktop webview host cannot safely assume that every `window.open()` can be denied and reconstructed as a URL-only navigation without side effects. @@ -44,7 +44,7 @@ A WebView host makes product decisions whenever it intercepts navigation: For Spark Desktop, the useful boundary is intentionally small: - Gemini Spark stays in-app. -- Google Docs and Sheets opened from Spark stay in-app. +- Google Drive, Docs, and Sheets opened from Spark launch in Google Chrome. - Spark Desktop does not try to become a general web browser. ## 4. Shared session state is essential @@ -92,9 +92,9 @@ The prototype suggests several capabilities that could be useful in a first-part 1. Native multi-tab Spark workspaces. 2. Persistent project/session restoration. -3. First-class handoff between Spark and Docs/Sheets without leaving the desktop context. +3. First-class handoff from Spark to Drive/Docs/Sheets with an explicit, reliable Chrome transition. 4. Native keyboard navigation across Spark workspaces. -5. Clear rules for opening related Google surfaces inside the same desktop task. +5. Clear rules for handing related Google Workspace surfaces to the supported browser environment. 6. Lightweight desktop update and lifecycle behavior without exposing browser complexity. ## 9. What would be useful to learn from Google / the community diff --git a/docs/OUTREACH_KIT.md b/docs/OUTREACH_KIT.md index e00bc2d..d244438 100644 --- a/docs/OUTREACH_KIT.md +++ b/docs/OUTREACH_KIT.md @@ -4,17 +4,17 @@ Use this material when introducing Spark Desktop publicly. Keep the project posi ## One-line description -**Spark Desktop is an independent Windows shell for Gemini Spark that adds native tabs, persistent sessions, Google Docs/Sheets continuity, and signed automatic updates without becoming a general-purpose browser.** +**Spark Desktop is an independent Windows shell for Gemini Spark that adds native tabs, persistent sessions, reliable Google Drive/Docs/Sheets handoff to Chrome, and signed automatic updates without becoming a general-purpose browser.** ## Short technical pitch -Spark Desktop explores what a focused first-class Windows workflow around Gemini Spark can look like while leaving the Gemini UI and Google authentication untouched. It uses Tauri 2, React/TypeScript, and Microsoft Edge WebView2, with child webviews for Spark and related Google Workspace documents. +Spark Desktop explores what a focused first-class Windows workflow around Gemini Spark can look like while leaving the Gemini UI and Google authentication untouched. It uses Tauri 2, React/TypeScript, and Microsoft Edge WebView2 for Spark, with an explicit Chrome handoff for Google Workspace documents. The most useful findings from the prototype are documented in [`GEMINI_SPARK_DESKTOP_FINDINGS.md`](GEMINI_SPARK_DESKTOP_FINDINGS.md). ## Suggested Gemini feedback text -> I built Spark Desktop, an independent Windows prototype that wraps Gemini Spark in a minimal native shell with multi-tab workspaces, persistent sessions, internal Google Docs/Sheets handoff, and signed automatic updates. While building it I documented several integration findings around WebView popup semantics, Workspace navigation, and desktop workflow continuity. I would value guidance on recommended embedding/integration boundaries for Gemini web surfaces in desktop WebView hosts. Project and findings: https://github.com/sufyanaser/Spark-Desktop +> I built Spark Desktop, an independent Windows prototype that wraps Gemini Spark in a minimal native shell with multi-tab workspaces, persistent sessions, reliable Google Drive/Docs/Sheets handoff to Chrome, and signed automatic updates. While building it I documented several integration findings around WebView popup semantics, Workspace navigation, and desktop workflow continuity. I would value guidance on recommended embedding/integration boundaries for Gemini web surfaces in desktop WebView hosts. Project and findings: https://github.com/sufyanaser/Spark-Desktop ## Suggested community post title @@ -24,7 +24,7 @@ The most useful findings from the prototype are documented in [`GEMINI_SPARK_DES Spark Desktop is a small independent Windows experiment built around a simple question: what desktop behavior is genuinely useful around Gemini Spark without reimplementing Gemini or turning the app into another browser? -The current prototype adds native tabs/windows, persistent Google session state, internal Google Docs/Sheets handoff, keyboard controls, and signed automatic updates. The most interesting part was not the shell UI; it was learning how modern Google Workspace flows behave when a WebView host intercepts popup/navigation behavior. +The current prototype adds native tabs/windows, persistent Google session state, Google Drive/Docs/Sheets handoff to Chrome, keyboard controls, and signed automatic updates. The most interesting part was not the shell UI; it was learning how modern Google Workspace flows behave when a WebView host intercepts popup/navigation behavior. I documented the product and integration findings here: https://github.com/sufyanaser/Spark-Desktop/blob/main/docs/GEMINI_SPARK_DESKTOP_FINDINGS.md @@ -40,7 +40,7 @@ Spark Desktop is independent and is not affiliated with, endorsed by, or sponsor **Built: Spark Desktop** — a focused Windows shell around Gemini Spark. -Native tabs. Persistent sessions. Docs/Sheets stay in the same desktop workspace. Signed automatic updates. No address bar, bookmarks, or attempt to rebuild Gemini. +Native tabs. Persistent sessions. Drive/Docs/Sheets open reliably in Chrome. Signed automatic updates. No address bar, bookmarks, or attempt to rebuild Gemini. The useful part of the experiment was the integration work: WebView2 popup semantics, Workspace navigation, session continuity, and defining where a desktop shell should stop. diff --git a/docs/PUBLIC_LAUNCH_CHECKLIST.md b/docs/PUBLIC_LAUNCH_CHECKLIST.md index 6784737..de41914 100644 --- a/docs/PUBLIC_LAUNCH_CHECKLIST.md +++ b/docs/PUBLIC_LAUNCH_CHECKLIST.md @@ -18,7 +18,7 @@ This checklist separates repository readiness from public promotion. - [ ] Confirm the latest installed release auto-updates successfully from the previous version. - [ ] Verify close/minimize/maximize/drag on a real Windows machine. - [ ] Verify Spark sign-in persists after restart. -- [ ] Verify Docs and Sheets opened by Spark remain inside Spark Desktop and load reliably. +- [ ] Verify Drive, Docs, and Sheets opened by Spark launch in Google Chrome and load reliably. - [ ] Verify a failed update check never blocks application launch. ## Public presentation diff --git a/docs/REPOSITORY_PRESENTATION.md b/docs/REPOSITORY_PRESENTATION.md index 357ea2a..3053fe6 100644 --- a/docs/REPOSITORY_PRESENTATION.md +++ b/docs/REPOSITORY_PRESENTATION.md @@ -21,8 +21,8 @@ The next high-value presentation asset is a public-safe demo, not more decorativ Recommended assets: 1. one clean hero screenshot showing Spark + the native tab bar; -2. one screenshot showing a Google Sheet or Doc opened as an internal tab using non-sensitive demo content; -3. one 20–40 second GIF/video showing Spark → Workspace tab handoff → tab switching → window controls; +2. one screenshot showing a Google Sheet or Doc handed off to Chrome using non-sensitive demo content; +3. one 20–40 second GIF/video showing Spark → Chrome Workspace handoff → Spark tab switching → window controls; 4. optional simple architecture diagram generated from the Mermaid model in README. Do not publish screenshots containing private accounts, documents, emails, prompts, avatars, phone numbers, or other personal information. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6e512e9..34c4664 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,6 @@ use serde::Serialize; +#[cfg(target_os = "windows")] +use std::{env, path::PathBuf, process::Command}; use tauri::{Emitter, Manager, WindowEvent}; use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, ShortcutState}; @@ -18,8 +20,9 @@ const SPARK_WORKSPACE_LINK_ROUTING_SCRIPT: &str = r#" const isWorkspaceUrl = (value) => { try { const url = new URL(String(value), window.location.href); - return url.origin === 'https://docs.google.com' && - (url.pathname.startsWith('/document/') || url.pathname.startsWith('/spreadsheets/')); + return url.origin === 'https://drive.google.com' || + (url.origin === 'https://docs.google.com' && + (url.pathname.startsWith('/document/') || url.pathname.startsWith('/spreadsheets/'))); } catch { return false; } @@ -61,19 +64,106 @@ fn is_shell_window_label(label: &str) -> bool { label == "main" || label.starts_with("window-") } -fn is_workspace_document(url: &tauri::Url) -> bool { - if url.host_str() != Some("docs.google.com") { - return false; +fn is_google_workspace_url(url: &tauri::Url) -> bool { + match url.host_str() { + Some("drive.google.com") => true, + Some("docs.google.com") => { + let path = url.path(); + path.starts_with("/document/") || path.starts_with("/spreadsheets/") + } + _ => false, } - - let path = url.path(); - path.starts_with("/document/") || path.starts_with("/spreadsheets/") } fn is_spark_url(url: &tauri::Url) -> bool { url.host_str() == Some("gemini.google.com") && url.path().starts_with("/spark") } +#[cfg(target_os = "windows")] +fn chrome_install_candidates() -> Vec { + ["LOCALAPPDATA", "PROGRAMFILES", "PROGRAMFILES(X86)"] + .into_iter() + .filter_map(|name| env::var_os(name).map(PathBuf::from)) + .map(|root| { + root.join("Google") + .join("Chrome") + .join("Application") + .join("chrome.exe") + }) + .collect() +} + +#[cfg(target_os = "windows")] +fn launch_google_chrome(url: &tauri::Url) -> Result<(), String> { + for executable in chrome_install_candidates() { + if executable.is_file() { + return Command::new(&executable) + .arg(url.as_str()) + .spawn() + .map(|_| ()) + .map_err(|error| format!("Google Chrome could not open the link: {error}")); + } + } + + const CHROME_REGISTRY_LAUNCH: &str = r#" +& { + param([string]$TargetUrl) + $ErrorActionPreference = 'Stop' + $registryKeys = @( + 'Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe', + 'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe' + ) + $chrome = $null + foreach ($key in $registryKeys) { + $chrome = (Get-ItemProperty -LiteralPath $key -ErrorAction SilentlyContinue).'(default)' + if ($chrome) { break } + } + if (-not $chrome) { throw 'Google Chrome is not installed.' } + Start-Process -FilePath $chrome -ArgumentList @($TargetUrl) +} +"#; + + let status = Command::new("powershell.exe") + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + CHROME_REGISTRY_LAUNCH, + ]) + .arg(url.as_str()) + .status() + .map_err(|error| format!("Google Chrome could not be located: {error}"))?; + + if status.success() { + Ok(()) + } else { + Err("Google Chrome is required to open Google Drive, Docs, and Sheets links.".into()) + } +} + +#[cfg(not(target_os = "windows"))] +fn launch_google_chrome(_url: &tauri::Url) -> Result<(), String> { + Err("Opening Google Workspace links in Chrome is supported on Windows only.".into()) +} + +#[tauri::command] +fn open_google_workspace_in_chrome(url: String) -> Result<(), String> { + let parsed_url: tauri::Url = url + .parse() + .map_err(|error| format!("Invalid Google Workspace URL: {error}"))?; + + if parsed_url.scheme() != "https" || !is_google_workspace_url(&parsed_url) { + return Err( + "Only HTTPS Google Drive, Docs, and Sheets links can be opened externally.".into(), + ); + } + + launch_google_chrome(&parsed_url) +} + fn register_app_shortcuts(app: &tauri::AppHandle) { let shortcuts = app.global_shortcut(); for shortcut in SHORTCUTS { @@ -101,7 +191,11 @@ fn reload_webview(app: tauri::AppHandle, label: String) -> Result<(), String> { } #[tauri::command] -fn control_window(app: tauri::AppHandle, window_label: String, action: String) -> Result<(), String> { +fn control_window( + app: tauri::AppHandle, + window_label: String, + action: String, +) -> Result<(), String> { if !is_shell_window_label(&window_label) { return Err("Window control is restricted to Spark Desktop shell windows.".into()); } @@ -191,14 +285,12 @@ async fn create_tab_webview( let title_window = parent.clone(); let title_label = label.clone(); - let mut builder = tauri::webview::WebviewBuilder::new( - label.clone(), - tauri::WebviewUrl::External(parsed_url), - ) - .focused(true) - .incognito(false) - .devtools(false) - .zoom_hotkeys_enabled(true); + let mut builder = + tauri::webview::WebviewBuilder::new(label.clone(), tauri::WebviewUrl::External(parsed_url)) + .focused(true) + .incognito(false) + .devtools(false) + .zoom_hotkeys_enabled(true); if route_workspace_navigation { let popup_window = parent.clone(); @@ -209,7 +301,16 @@ async fn create_tab_webview( builder = builder .initialization_script(SPARK_WORKSPACE_LINK_ROUTING_SCRIPT) .on_new_window(move |new_url, _features| { - if is_workspace_document(&new_url) || is_spark_url(&new_url) { + if is_google_workspace_url(&new_url) { + let _ = popup_window.emit( + "spark-open-google-workspace", + OpenUrlRequest { + url: new_url.to_string(), + source_label: popup_source_label.clone(), + }, + ); + tauri::webview::NewWindowResponse::Deny + } else if is_spark_url(&new_url) { let _ = popup_window.emit( "spark-open-url", OpenUrlRequest { @@ -223,9 +324,9 @@ async fn create_tab_webview( } }) .on_navigation(move |new_url| { - if is_workspace_document(new_url) { + if is_google_workspace_url(new_url) { let _ = navigation_window.emit( - "spark-open-url", + "spark-open-google-workspace", OpenUrlRequest { url: new_url.to_string(), source_label: navigation_source_label.clone(), @@ -274,11 +375,14 @@ pub fn run() { Some("new-tab") } else if shortcut.matches(Modifiers::CONTROL, Code::KeyW) { Some("close-tab") - } else if shortcut.matches(Modifiers::CONTROL | Modifiers::SHIFT, Code::Tab) { + } else if shortcut.matches(Modifiers::CONTROL | Modifiers::SHIFT, Code::Tab) + { Some("previous-tab") } else if shortcut.matches(Modifiers::CONTROL, Code::Tab) { Some("next-tab") - } else if shortcut.matches(Modifiers::CONTROL | Modifiers::SHIFT, Code::KeyN) { + } else if shortcut + .matches(Modifiers::CONTROL | Modifiers::SHIFT, Code::KeyN) + { Some("new-window") } else if shortcut.matches(Modifiers::CONTROL, Code::KeyR) { Some("reload-tab") @@ -321,6 +425,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ reload_webview, control_window, + open_google_workspace_in_chrome, create_shell_window, create_tab_webview ]) @@ -329,3 +434,36 @@ pub fn run() { .run(tauri::generate_context!()) .expect("error while running Spark Desktop"); } + +#[cfg(test)] +mod tests { + use super::is_google_workspace_url; + + fn parsed(url: &str) -> tauri::Url { + url.parse().expect("test URL should be valid") + } + + #[test] + fn recognizes_google_drive_docs_and_sheets_urls() { + for url in [ + "https://drive.google.com/file/d/123/view", + "https://drive.google.com/open?id=123", + "https://docs.google.com/document/d/123/edit", + "https://docs.google.com/spreadsheets/d/123/edit", + ] { + assert!(is_google_workspace_url(&parsed(url)), "{url}"); + } + } + + #[test] + fn rejects_unrelated_or_lookalike_urls() { + for url in [ + "https://gemini.google.com/spark/abc", + "https://docs.google.com/presentation/d/123/edit", + "https://drive.google.com.example.com/file/d/123/view", + "https://example.com/https://drive.google.com/file/d/123/view", + ] { + assert!(!is_google_workspace_url(&parsed(url)), "{url}"); + } + } +} diff --git a/src/App.tsx b/src/App.tsx index f6bc4c4..3b85994 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { closeTabWebview, controlCurrentWindow, createShellWindow, + openGoogleWorkspaceInChrome, reloadTab, resizeTab, type WindowControlAction, @@ -241,6 +242,7 @@ export default function App() { useEffect(() => { let disposed = false; let unlistenOpenUrl: (() => void) | undefined; + let unlistenGoogleWorkspace: (() => void) | undefined; let unlistenTitle: (() => void) | undefined; void listen('spark-open-url', (event) => { @@ -250,6 +252,16 @@ export default function App() { else unlistenOpenUrl = unlisten; }); + void listen('spark-open-google-workspace', (event) => { + if (disposed) return; + void openGoogleWorkspaceInChrome(event.payload.url).catch((error) => { + setRuntimeError(error instanceof Error ? error.message : String(error)); + }); + }).then((unlisten) => { + if (disposed) unlisten(); + else unlistenGoogleWorkspace = unlisten; + }); + void listen('spark-tab-title', (event) => { if (disposed) return; const title = event.payload.title.trim(); @@ -269,6 +281,7 @@ export default function App() { return () => { disposed = true; unlistenOpenUrl?.(); + unlistenGoogleWorkspace?.(); unlistenTitle?.(); }; }, [addUrlTab, commitState]); diff --git a/src/lib/webviews.ts b/src/lib/webviews.ts index a5c9602..e3c5e5d 100644 --- a/src/lib/webviews.ts +++ b/src/lib/webviews.ts @@ -73,6 +73,10 @@ export async function reloadTab(tab: SparkTab): Promise { await invoke('reload_webview', { label: tab.label }); } +export async function openGoogleWorkspaceInChrome(url: string): Promise { + await invoke('open_google_workspace_in_chrome', { url }); +} + export async function closeTabWebview(tab: SparkTab): Promise { const handle = handles.get(tab.id) ?? (await Webview.getByLabel(tab.label)); handles.delete(tab.id);