Work App: a dedicated browser for work tools
A Tauri 2 shell with one child webview per configured tool. Nav on the left with groups and a collapsible icon rail; links between configured apps switch tabs, everything else leaves for the real browser. Design spec in docs/superpowers/specs/2026-09-01-work-app-design.md.
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "work-app"
|
||||
version = "0.1.0"
|
||||
description = "A browser for the tools you work in"
|
||||
authors = ["Vincent"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "work_app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
# Pinned: multi-webview lives behind `unstable`, whose API can move between minors.
|
||||
tauri = { version = "=2.11.5", features = ["unstable"] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "The shell webview. App webviews are deliberately absent: remote content gets no IPC.",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"The shell webview. App webviews are deliberately absent: remote content gets no IPC.","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","opener:default"]}}
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 304 B |
@@ -0,0 +1,363 @@
|
||||
//! The command surface. The shell owns "which app is active"; Rust owns the
|
||||
//! webviews and the file on disk.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use url::Url;
|
||||
|
||||
use crate::config::{self, App, Config, Group};
|
||||
use crate::webviews;
|
||||
|
||||
/// Stage rect in logical pixels: x, y, width, height.
|
||||
pub type Stage = (f64, f64, f64, f64);
|
||||
|
||||
pub struct AppState {
|
||||
pub dir: PathBuf,
|
||||
pub config: Mutex<Config>,
|
||||
pub active: Mutex<Option<String>>,
|
||||
pub stage: Mutex<Stage>,
|
||||
/// Webviews exist. Guards against bootstrapping twice on a hot reload.
|
||||
pub booted: Mutex<bool>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn cfg(&self) -> Config {
|
||||
self.config.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn persist(&self) -> Result<(), String> {
|
||||
config::save(&self.dir, &self.config.lock().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
|
||||
let dir = handle
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.map_err(|e| format!("no config directory: {e}"))?;
|
||||
let config = config::load(&dir)?;
|
||||
Ok(AppState {
|
||||
dir,
|
||||
config: Mutex::new(config),
|
||||
active: Mutex::new(None),
|
||||
stage: Mutex::new((240.0, 38.0, 800.0, 600.0)),
|
||||
booted: Mutex::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_config(state: State<'_, AppState>) -> Config {
|
||||
state.cfg()
|
||||
}
|
||||
|
||||
/// Records the rect the shell has left for an app, and resizes the one showing.
|
||||
#[tauri::command]
|
||||
pub fn set_stage(
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) {
|
||||
let stage = (x, y, width.max(0.0), height.max(0.0));
|
||||
*state.stage.lock().unwrap() = stage;
|
||||
let active = state.active.lock().unwrap().clone();
|
||||
webviews::set_stage(&app, active.as_deref(), stage);
|
||||
}
|
||||
|
||||
/// Creates every app's webview: the active one first, the rest staggered.
|
||||
///
|
||||
/// Called by the shell once it has measured the stage, so the views are born
|
||||
/// at the right size instead of being built against a guess and corrected.
|
||||
#[tauri::command]
|
||||
pub fn bootstrap(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
|
||||
{
|
||||
let mut booted = state.booted.lock().unwrap();
|
||||
if *booted {
|
||||
return Ok(());
|
||||
}
|
||||
*booted = true;
|
||||
}
|
||||
|
||||
let cfg = state.cfg();
|
||||
let stage = *state.stage.lock().unwrap();
|
||||
let active = state.active.lock().unwrap().clone();
|
||||
|
||||
let first = active
|
||||
.clone()
|
||||
.or_else(|| cfg.ordered().first().map(|a| a.id.clone()));
|
||||
|
||||
if let Some(id) = &first {
|
||||
if let Some(a) = cfg.app(id) {
|
||||
webviews::create(&app, a, &cfg, stage)?;
|
||||
*state.active.lock().unwrap() = Some(id.clone());
|
||||
webviews::show_only(&app, Some(id), &cfg, stage);
|
||||
}
|
||||
}
|
||||
|
||||
// The rest follow with a gap, so launching does not fire a dozen
|
||||
// simultaneous page loads at the network and the CPU.
|
||||
let rest: Vec<App> = cfg
|
||||
.ordered()
|
||||
.into_iter()
|
||||
.filter(|a| Some(&a.id) != first.as_ref())
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
for a in rest {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
|
||||
let state = app.state::<AppState>();
|
||||
let cfg = state.cfg();
|
||||
let stage = *state.stage.lock().unwrap();
|
||||
if let Err(e) = webviews::create(&app, &a, &cfg, stage) {
|
||||
eprintln!("could not create webview for {}: {e}", a.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_active(app_id: String, app: AppHandle, state: State<'_, AppState>) {
|
||||
let cfg = state.cfg();
|
||||
let stage = *state.stage.lock().unwrap();
|
||||
*state.active.lock().unwrap() = Some(app_id.clone());
|
||||
webviews::show_only(&app, Some(&app_id), &cfg, stage);
|
||||
}
|
||||
|
||||
/// Hides every app, so a dialog is not painted over by a native view.
|
||||
#[tauri::command]
|
||||
pub fn hide_stage(app: AppHandle, state: State<'_, AppState>) {
|
||||
webviews::hide_all(&app, &state.cfg());
|
||||
}
|
||||
|
||||
/// Puts the active app back after a dialog closes.
|
||||
#[tauri::command]
|
||||
pub fn show_stage(app: AppHandle, state: State<'_, AppState>) {
|
||||
let cfg = state.cfg();
|
||||
let stage = *state.stage.lock().unwrap();
|
||||
let active = state.active.lock().unwrap().clone();
|
||||
webviews::show_only(&app, active.as_deref(), &cfg, stage);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn navigate_app(app_id: String, url: String, app: AppHandle) -> Result<(), String> {
|
||||
let wv = app
|
||||
.get_webview(&webviews::label_for(&app_id))
|
||||
.ok_or_else(|| format!("{app_id} has no webview"))?;
|
||||
let url = Url::parse(&url).map_err(|e| e.to_string())?;
|
||||
wv.navigate(url).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Back, forward and reload, run inside the app's own page.
|
||||
#[tauri::command]
|
||||
pub fn history_go(app_id: String, delta: i32, app: AppHandle) -> Result<(), String> {
|
||||
let wv = app
|
||||
.get_webview(&webviews::label_for(&app_id))
|
||||
.ok_or_else(|| format!("{app_id} has no webview"))?;
|
||||
let script = if delta == 0 {
|
||||
"location.reload()".to_string()
|
||||
} else {
|
||||
format!("history.go({delta})")
|
||||
};
|
||||
wv.eval(&script).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn current_url(app_id: String, app: AppHandle) -> Option<String> {
|
||||
app.get_webview(&webviews::label_for(&app_id))
|
||||
.and_then(|wv| wv.url().ok())
|
||||
.map(|u| u.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_external(url: String) -> Result<(), String> {
|
||||
tauri_plugin_opener::open_url(url, None::<&str>).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- apps
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_app(
|
||||
name: String,
|
||||
url: String,
|
||||
group_id: Option<String>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Config, String> {
|
||||
let url = config::normalize_url(&url);
|
||||
let scope = config::default_scope(&url)
|
||||
.ok_or_else(|| format!("{url} is not a URL this can open"))?;
|
||||
|
||||
let new = {
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
let order = cfg
|
||||
.apps
|
||||
.iter()
|
||||
.filter(|a| a.group_id == group_id)
|
||||
.map(|a| a.order)
|
||||
.max()
|
||||
.map_or(0, |m| m + 1);
|
||||
let new = App {
|
||||
id: config::new_id(),
|
||||
name: name.trim().to_string(),
|
||||
url,
|
||||
scope: vec![scope],
|
||||
group_id,
|
||||
user_agent: None,
|
||||
order,
|
||||
};
|
||||
cfg.apps.push(new.clone());
|
||||
new
|
||||
};
|
||||
state.persist()?;
|
||||
|
||||
let cfg = state.cfg();
|
||||
let stage = *state.stage.lock().unwrap();
|
||||
if *state.booted.lock().unwrap() {
|
||||
webviews::create(&app, &new, &cfg, stage)?;
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// Applies an edit. A change to url, scope or user agent rebuilds the webview,
|
||||
/// since none of the three can be altered on a live one.
|
||||
#[tauri::command]
|
||||
pub fn update_app(
|
||||
updated: App,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Config, String> {
|
||||
let rebuild = {
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
let existing = cfg
|
||||
.apps
|
||||
.iter_mut()
|
||||
.find(|a| a.id == updated.id)
|
||||
.ok_or_else(|| format!("no app {}", updated.id))?;
|
||||
let rebuild = existing.url != updated.url
|
||||
|| existing.scope != updated.scope
|
||||
|| existing.user_agent != updated.user_agent;
|
||||
*existing = updated.clone();
|
||||
rebuild
|
||||
};
|
||||
state.persist()?;
|
||||
|
||||
let cfg = state.cfg();
|
||||
let stage = *state.stage.lock().unwrap();
|
||||
if rebuild && *state.booted.lock().unwrap() {
|
||||
webviews::destroy(&app, &updated.id);
|
||||
webviews::create(&app, &updated, &cfg, stage)?;
|
||||
let active = state.active.lock().unwrap().clone();
|
||||
webviews::show_only(&app, active.as_deref(), &cfg, stage);
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_app(
|
||||
app_id: String,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Config, String> {
|
||||
{
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
cfg.apps.retain(|a| a.id != app_id);
|
||||
}
|
||||
state.persist()?;
|
||||
webviews::destroy(&app, &app_id);
|
||||
|
||||
let mut active = state.active.lock().unwrap();
|
||||
if active.as_deref() == Some(app_id.as_str()) {
|
||||
*active = None;
|
||||
}
|
||||
Ok(state.cfg())
|
||||
}
|
||||
|
||||
/// Rewrites group membership and position in one go, for a drag.
|
||||
#[tauri::command]
|
||||
pub fn reorder_apps(
|
||||
ordering: Vec<(String, Option<String>, i32)>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Config, String> {
|
||||
{
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
for (id, group_id, order) in ordering {
|
||||
if let Some(a) = cfg.apps.iter_mut().find(|a| a.id == id) {
|
||||
a.group_id = group_id;
|
||||
a.order = order;
|
||||
}
|
||||
}
|
||||
}
|
||||
state.persist()?;
|
||||
Ok(state.cfg())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- groups
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_group(name: String, state: State<'_, AppState>) -> Result<Config, String> {
|
||||
{
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
let order = cfg.groups.iter().map(|g| g.order).max().map_or(0, |m| m + 1);
|
||||
cfg.groups.push(Group {
|
||||
id: config::new_id(),
|
||||
name: name.trim().to_string(),
|
||||
collapsed: false,
|
||||
order,
|
||||
});
|
||||
}
|
||||
state.persist()?;
|
||||
Ok(state.cfg())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_group(updated: Group, state: State<'_, AppState>) -> Result<Config, String> {
|
||||
{
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
let g = cfg
|
||||
.groups
|
||||
.iter_mut()
|
||||
.find(|g| g.id == updated.id)
|
||||
.ok_or_else(|| format!("no group {}", updated.id))?;
|
||||
*g = updated;
|
||||
}
|
||||
state.persist()?;
|
||||
Ok(state.cfg())
|
||||
}
|
||||
|
||||
/// Removes a group. Its apps survive, ungrouped — deleting a folder should
|
||||
/// never be a way to lose the things inside it by accident.
|
||||
#[tauri::command]
|
||||
pub fn delete_group(group_id: String, state: State<'_, AppState>) -> Result<Config, String> {
|
||||
{
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
cfg.groups.retain(|g| g.id != group_id);
|
||||
for a in cfg.apps.iter_mut() {
|
||||
if a.group_id.as_deref() == Some(group_id.as_str()) {
|
||||
a.group_id = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
state.persist()?;
|
||||
Ok(state.cfg())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ settings
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_nav_collapsed(collapsed: bool, state: State<'_, AppState>) -> Result<(), String> {
|
||||
state.config.lock().unwrap().settings.nav_collapsed = collapsed;
|
||||
state.persist()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_theme(theme: String, state: State<'_, AppState>) -> Result<(), String> {
|
||||
state.config.lock().unwrap().settings.theme = theme;
|
||||
state.persist()
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//! `apps.json`: the list of tools, their groups, and the window's preferences.
|
||||
//!
|
||||
//! Apps are flat and carry a `group_id` rather than being nested inside their
|
||||
//! group, so dragging one between groups is a field change instead of a tree
|
||||
//! rewrite.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use url::Url;
|
||||
|
||||
use crate::routing::AppScope;
|
||||
|
||||
/// What the app claims to be. WebKit's own user agent gets a Google login
|
||||
/// refused as "not a secure browser", which would make half the list unusable.
|
||||
pub const CHROME_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
|
||||
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct App {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
/// Hosts this app owns. Defaults to the URL's exact host — `mail.google.com`
|
||||
/// rather than `google.com`, or Gmail and Drive each swallow the other.
|
||||
#[serde(default)]
|
||||
pub scope: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub group_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub user_agent: Option<String>,
|
||||
#[serde(default)]
|
||||
pub order: i32,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn scopes(&self) -> Vec<String> {
|
||||
if self.scope.is_empty() {
|
||||
default_scope(&self.url).into_iter().collect()
|
||||
} else {
|
||||
self.scope.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ua(&self) -> String {
|
||||
self.user_agent.clone().unwrap_or_else(|| CHROME_UA.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Group {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub collapsed: bool,
|
||||
#[serde(default)]
|
||||
pub order: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Settings {
|
||||
#[serde(default)]
|
||||
pub nav_collapsed: bool,
|
||||
#[serde(default = "default_theme")]
|
||||
pub theme: String,
|
||||
#[serde(default)]
|
||||
pub paired_browser: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_paired_at: Option<String>,
|
||||
}
|
||||
|
||||
fn default_theme() -> String {
|
||||
"system".into()
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
nav_collapsed: false,
|
||||
theme: default_theme(),
|
||||
paired_browser: None,
|
||||
last_paired_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Config {
|
||||
#[serde(default = "default_version")]
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
pub groups: Vec<Group>,
|
||||
#[serde(default)]
|
||||
pub apps: Vec<App>,
|
||||
#[serde(default)]
|
||||
pub settings: Settings,
|
||||
}
|
||||
|
||||
fn default_version() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: 1,
|
||||
groups: Vec::new(),
|
||||
apps: Vec::new(),
|
||||
settings: Settings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Scopes in the shape routing wants.
|
||||
pub fn scopes(&self) -> Vec<AppScope> {
|
||||
self.apps
|
||||
.iter()
|
||||
.map(|a| AppScope { id: a.id.clone(), scope: a.scopes() })
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn app(&self, id: &str) -> Option<&App> {
|
||||
self.apps.iter().find(|a| a.id == id)
|
||||
}
|
||||
|
||||
/// Apps in the order the nav shows them: by group, then by position.
|
||||
pub fn ordered(&self) -> Vec<&App> {
|
||||
let group_rank = |id: &Option<String>| -> i32 {
|
||||
match id {
|
||||
None => -1,
|
||||
Some(g) => self
|
||||
.groups
|
||||
.iter()
|
||||
.find(|x| &x.id == g)
|
||||
.map(|x| x.order)
|
||||
.unwrap_or(i32::MAX),
|
||||
}
|
||||
};
|
||||
let mut apps: Vec<&App> = self.apps.iter().collect();
|
||||
apps.sort_by_key(|a| (group_rank(&a.group_id), a.order));
|
||||
apps
|
||||
}
|
||||
}
|
||||
|
||||
/// The host of a URL, which is the app's scope until the user widens it.
|
||||
pub fn default_scope(url: &str) -> Option<String> {
|
||||
Url::parse(url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(|h| h.trim_start_matches("www.").to_string()))
|
||||
}
|
||||
|
||||
/// Normalises what someone types into an app's URL field.
|
||||
pub fn normalize_url(input: &str) -> String {
|
||||
let t = input.trim();
|
||||
if t.starts_with("http://") || t.starts_with("https://") {
|
||||
t.to_string()
|
||||
} else {
|
||||
format!("https://{t}")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// First-run contents: enough apps, across enough groups, that grouping and
|
||||
/// cross-app link routing can both be seen working without typing anything.
|
||||
pub fn seed() -> Config {
|
||||
let mk = |name: &str, url: &str, group: &str, order: i32| App {
|
||||
id: new_id(),
|
||||
name: name.into(),
|
||||
url: url.into(),
|
||||
scope: default_scope(url).into_iter().collect(),
|
||||
group_id: Some(group.into()),
|
||||
user_agent: None,
|
||||
order,
|
||||
};
|
||||
Config {
|
||||
version: 1,
|
||||
groups: vec![
|
||||
Group { id: "g-google".into(), name: "Google".into(), collapsed: false, order: 0 },
|
||||
Group { id: "g-dev".into(), name: "Dev".into(), collapsed: false, order: 1 },
|
||||
Group { id: "g-ref".into(), name: "Reference".into(), collapsed: false, order: 2 },
|
||||
],
|
||||
apps: vec![
|
||||
mk("Google", "https://www.google.com", "g-google", 0),
|
||||
mk("YouTube", "https://www.youtube.com", "g-google", 1),
|
||||
mk("GitHub", "https://github.com", "g-dev", 0),
|
||||
mk("Hacker News", "https://news.ycombinator.com", "g-dev", 1),
|
||||
mk("Wikipedia", "https://en.wikipedia.org", "g-ref", 0),
|
||||
mk("MDN", "https://developer.mozilla.org", "g-ref", 1),
|
||||
],
|
||||
settings: Settings::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config_path(dir: &PathBuf) -> PathBuf {
|
||||
dir.join("apps.json")
|
||||
}
|
||||
|
||||
/// Reads `apps.json`, seeding it on first run.
|
||||
///
|
||||
/// A file that fails to parse is kept, not replaced: losing someone's app list
|
||||
/// to a bad write is worse than starting with the seed and telling them.
|
||||
pub fn load(dir: &PathBuf) -> Result<Config, String> {
|
||||
let path = config_path(dir);
|
||||
if !path.exists() {
|
||||
let cfg = seed();
|
||||
save(dir, &cfg)?;
|
||||
return Ok(cfg);
|
||||
}
|
||||
let raw = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||
serde_json::from_str(&raw).map_err(|e| format!("{} is not readable: {e}", path.display()))
|
||||
}
|
||||
|
||||
pub fn save(dir: &PathBuf, cfg: &Config) -> Result<(), String> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
let json = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
|
||||
std::fs::write(config_path(dir), json).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_scope_is_the_host_without_www() {
|
||||
assert_eq!(default_scope("https://www.google.com/x"), Some("google.com".into()));
|
||||
assert_eq!(default_scope("https://mail.google.com"), Some("mail.google.com".into()));
|
||||
assert_eq!(default_scope("nonsense"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_url_adds_a_scheme_but_keeps_an_explicit_one() {
|
||||
assert_eq!(normalize_url(" github.com "), "https://github.com");
|
||||
assert_eq!(normalize_url("http://intranet.local"), "http://intranet.local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_apps_all_carry_a_scope() {
|
||||
let cfg = seed();
|
||||
assert_eq!(cfg.apps.len(), 6);
|
||||
assert!(cfg.apps.iter().all(|a| !a.scopes().is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordering_follows_group_then_position() {
|
||||
let cfg = seed();
|
||||
let names: Vec<&str> = cfg.ordered().iter().map(|a| a.name.as_str()).collect();
|
||||
assert_eq!(names, ["Google", "YouTube", "GitHub", "Hacker News", "Wikipedia", "MDN"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_survives_a_serde_round_trip() {
|
||||
let cfg = seed();
|
||||
let json = serde_json::to_string(&cfg).unwrap();
|
||||
let back: Config = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.apps.len(), cfg.apps.len());
|
||||
assert_eq!(back.groups.len(), cfg.groups.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sparse_file_fills_in_its_defaults() {
|
||||
// Hand-edited config files are a supported way to add an app.
|
||||
let cfg: Config = serde_json::from_str(
|
||||
r#"{"apps":[{"id":"a","name":"X","url":"https://x.com"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cfg.version, 1);
|
||||
assert_eq!(cfg.settings.theme, "system");
|
||||
assert_eq!(cfg.apps[0].scopes(), vec!["x.com".to_string()]);
|
||||
assert!(cfg.apps[0].ua().contains("Chrome/"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod routing;
|
||||
pub mod webviews;
|
||||
|
||||
use tauri::menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu};
|
||||
use tauri::Manager;
|
||||
|
||||
/// The macOS menu bar. Edit has no entry of its own, but its items live under
|
||||
/// the app menu because they are what make ⌘X/⌘C/⌘V work in a text field.
|
||||
fn build_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
|
||||
let app_menu = Submenu::with_items(
|
||||
app,
|
||||
"Work",
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::about(app, None, Some(AboutMetadata::default()))?,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&PredefinedMenuItem::hide(app, None)?,
|
||||
&PredefinedMenuItem::hide_others(app, None)?,
|
||||
&PredefinedMenuItem::show_all(app, None)?,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&PredefinedMenuItem::cut(app, None)?,
|
||||
&PredefinedMenuItem::copy(app, None)?,
|
||||
&PredefinedMenuItem::paste(app, None)?,
|
||||
&PredefinedMenuItem::select_all(app, None)?,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&PredefinedMenuItem::quit(app, None)?,
|
||||
],
|
||||
)?;
|
||||
|
||||
let window_menu = Submenu::with_items(
|
||||
app,
|
||||
"Window",
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::minimize(app, None)?,
|
||||
&PredefinedMenuItem::maximize(app, None)?,
|
||||
&PredefinedMenuItem::fullscreen(app, None)?,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&PredefinedMenuItem::close_window(app, None)?,
|
||||
],
|
||||
)?;
|
||||
|
||||
Menu::with_items(app, &[&app_menu, &window_menu])
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.menu(build_menu)
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.setup(|app| {
|
||||
let state = commands::build_state(&app.handle().clone())?;
|
||||
app.manage(state);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_config,
|
||||
commands::bootstrap,
|
||||
commands::set_stage,
|
||||
commands::set_active,
|
||||
commands::hide_stage,
|
||||
commands::show_stage,
|
||||
commands::navigate_app,
|
||||
commands::history_go,
|
||||
commands::current_url,
|
||||
commands::open_external,
|
||||
commands::add_app,
|
||||
commands::update_app,
|
||||
commands::delete_app,
|
||||
commands::reorder_apps,
|
||||
commands::add_group,
|
||||
commands::update_group,
|
||||
commands::delete_group,
|
||||
commands::set_nav_collapsed,
|
||||
commands::set_theme,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents an additional console window on Windows in release.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
work_app_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Where a URL should open: here, another app, or the real browser.
|
||||
//!
|
||||
//! Pure. No I/O, no Tauri types, no webviews — which is what makes the rules
|
||||
//! testable, and they are the rules the whole app is judged on.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
/// Hosts that always stay inside the app, whichever app is showing.
|
||||
///
|
||||
/// A "Sign in with Google" button is a user click to a foreign host. Without
|
||||
/// this list the ordinary rule hands it to the external browser, and the login
|
||||
/// completes over there — in the one place whose cookies this app cannot see.
|
||||
const IDENTITY_PROVIDERS: &[&str] = &[
|
||||
"accounts.google.com",
|
||||
"accounts.youtube.com",
|
||||
"login.microsoftonline.com",
|
||||
"login.microsoft.com",
|
||||
"login.live.com",
|
||||
"login.windows.net",
|
||||
"sts.windows.net",
|
||||
"device.login.microsoftonline.com",
|
||||
"okta.com",
|
||||
"oktapreview.com",
|
||||
"auth0.com",
|
||||
"duosecurity.com",
|
||||
"appleid.apple.com",
|
||||
"signin.aws.amazon.com",
|
||||
"accounts.zoho.com",
|
||||
"id.atlassian.com",
|
||||
"auth.atlassian.com",
|
||||
"slack.com",
|
||||
"onelogin.com",
|
||||
"pingidentity.com",
|
||||
"authsvc.teams.microsoft.com",
|
||||
];
|
||||
|
||||
/// The scope of one configured app, as routing needs it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppScope {
|
||||
pub id: String,
|
||||
/// Hosts this app owns. A URL matches on an exact host or a subdomain.
|
||||
pub scope: Vec<String>,
|
||||
}
|
||||
|
||||
/// What to do with a URL a webview is about to open.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Decision {
|
||||
/// Let the current webview have it.
|
||||
Stay,
|
||||
/// Another configured app owns this host: switch to it and navigate there.
|
||||
Switch { app_id: String, url: String },
|
||||
/// Not ours. Hand it to the default browser.
|
||||
External { url: String },
|
||||
}
|
||||
|
||||
/// True when `host` is `scope` or a subdomain of it.
|
||||
///
|
||||
/// Written out rather than a suffix test, because `evilgoogle.com` ends with
|
||||
/// `google.com` and must not match it.
|
||||
pub fn host_matches(host: &str, scope: &str) -> bool {
|
||||
let host = host.trim_start_matches("www.").to_ascii_lowercase();
|
||||
let scope = scope.trim_start_matches("www.").to_ascii_lowercase();
|
||||
host == scope || host.ends_with(&format!(".{scope}"))
|
||||
}
|
||||
|
||||
fn is_identity_provider(host: &str) -> bool {
|
||||
IDENTITY_PROVIDERS.iter().any(|p| host_matches(host, p))
|
||||
}
|
||||
|
||||
/// The app whose scope matches `host` most specifically.
|
||||
///
|
||||
/// Longest match wins, so an app scoped to `mail.google.com` beats one scoped
|
||||
/// to `google.com` for a Gmail URL rather than the answer depending on order.
|
||||
fn best_match<'a>(host: &str, apps: &'a [AppScope]) -> Option<&'a AppScope> {
|
||||
apps.iter()
|
||||
.filter_map(|a| {
|
||||
a.scope
|
||||
.iter()
|
||||
.filter(|s| host_matches(host, s))
|
||||
.map(|s| s.len())
|
||||
.max()
|
||||
.map(|len| (len, a))
|
||||
})
|
||||
.max_by_key(|(len, _)| *len)
|
||||
.map(|(_, a)| a)
|
||||
}
|
||||
|
||||
/// Decide where `url` opens, given which app is showing.
|
||||
///
|
||||
/// `current` is the id of the app the click came from; `None` means the
|
||||
/// decision is being made outside any app.
|
||||
pub fn decide(url: &str, current: Option<&str>, apps: &[AppScope]) -> Decision {
|
||||
let Ok(parsed) = Url::parse(url) else {
|
||||
return Decision::External { url: url.to_string() };
|
||||
};
|
||||
|
||||
// mailto:, tel:, zoommtg: and friends are for the OS to place, not us.
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
return Decision::External { url: url.to_string() };
|
||||
}
|
||||
|
||||
let Some(host) = parsed.host_str() else {
|
||||
return Decision::External { url: url.to_string() };
|
||||
};
|
||||
|
||||
// The app showing right now gets first refusal, so an app whose scope
|
||||
// overlaps another's never steals its own internal navigation.
|
||||
if let Some(id) = current {
|
||||
if let Some(app) = apps.iter().find(|a| a.id == id) {
|
||||
if app.scope.iter().any(|s| host_matches(host, s)) {
|
||||
return Decision::Stay;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if is_identity_provider(host) {
|
||||
return Decision::Stay;
|
||||
}
|
||||
|
||||
match best_match(host, apps) {
|
||||
Some(app) => Decision::Switch {
|
||||
app_id: app.id.clone(),
|
||||
url: url.to_string(),
|
||||
},
|
||||
None => Decision::External { url: url.to_string() },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn apps() -> Vec<AppScope> {
|
||||
vec![
|
||||
AppScope { id: "gmail".into(), scope: vec!["mail.google.com".into()] },
|
||||
AppScope { id: "drive".into(), scope: vec!["drive.google.com".into()] },
|
||||
AppScope { id: "google".into(), scope: vec!["google.com".into()] },
|
||||
AppScope { id: "gh".into(), scope: vec!["github.com".into()] },
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_matches_exact_and_subdomain() {
|
||||
assert!(host_matches("github.com", "github.com"));
|
||||
assert!(host_matches("gist.github.com", "github.com"));
|
||||
assert!(host_matches("www.github.com", "github.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_matches_rejects_suffix_lookalikes() {
|
||||
assert!(!host_matches("evilgithub.com", "github.com"));
|
||||
assert!(!host_matches("github.com.evil.net", "github.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stays_within_the_current_app() {
|
||||
let d = decide("https://mail.google.com/mail/u/0/#inbox", Some("gmail"), &apps());
|
||||
assert_eq!(d, Decision::Stay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switches_to_the_app_that_owns_the_host() {
|
||||
let d = decide("https://drive.google.com/file/d/123", Some("gmail"), &apps());
|
||||
assert_eq!(
|
||||
d,
|
||||
Decision::Switch { app_id: "drive".into(), url: "https://drive.google.com/file/d/123".into() }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longest_scope_wins_over_a_broader_one() {
|
||||
// Both `google.com` and `mail.google.com` match; the specific app must win.
|
||||
let d = decide("https://mail.google.com/", Some("gh"), &apps());
|
||||
assert!(matches!(d, Decision::Switch { ref app_id, .. } if app_id == "gmail"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_hosts_leave_for_the_browser() {
|
||||
let d = decide("https://news.ycombinator.com/", Some("gh"), &apps());
|
||||
assert_eq!(d, Decision::External { url: "https://news.ycombinator.com/".into() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_providers_stay_inside() {
|
||||
// Otherwise every SSO login completes in a browser this app cannot read.
|
||||
let d = decide("https://accounts.google.com/o/oauth2/auth?x=1", Some("gh"), &apps());
|
||||
assert_eq!(d, Decision::Stay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_web_schemes_go_to_the_os() {
|
||||
let d = decide("mailto:someone@example.com", Some("gmail"), &apps());
|
||||
assert!(matches!(d, Decision::External { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_urls_do_not_panic() {
|
||||
assert!(matches!(decide("not a url", None, &apps()), Decision::External { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_app_wins_even_when_another_scope_is_longer() {
|
||||
// `google` is showing and clicks a google.com link: it keeps it, rather
|
||||
// than the more specific gmail app stealing an internal navigation.
|
||||
let d = decide("https://google.com/search?q=x", Some("google"), &apps());
|
||||
assert_eq!(d, Decision::Stay);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! One child webview per configured app, stacked in the stage rect.
|
||||
//!
|
||||
//! Child webviews are native views layered above the shell's content. They take
|
||||
//! no part in CSS layout, so the shell measures the stage and reports it here,
|
||||
//! and anything the shell wants to draw over an app (a dialog) requires hiding
|
||||
//! the app first.
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{
|
||||
AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, WebviewUrl,
|
||||
webview::{NewWindowResponse, WebviewBuilder},
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
use crate::config::{App, Config};
|
||||
use crate::routing::{self, AppScope, Decision};
|
||||
|
||||
/// Scheme the injected interceptor uses to hand a URL back for a decision.
|
||||
///
|
||||
/// A made-up scheme rather than Tauri IPC: IPC to a remote origin means
|
||||
/// granting google.com the ability to call into this app, and routing a link
|
||||
/// does not need anything that dangerous. `on_navigation` sees this, answers
|
||||
/// it, and cancels the navigation — so nothing ever loads.
|
||||
const ROUTE_SCHEME: &str = "workapp-route";
|
||||
|
||||
pub fn label_for(app_id: &str) -> String {
|
||||
format!("app-{app_id}")
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SwitchEvent {
|
||||
pub app_id: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UrlEvent {
|
||||
pub app_id: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// The click interceptor, specialised for one app's scope.
|
||||
///
|
||||
/// It only decides *intent*: a link the user clicked that leaves this app's
|
||||
/// hosts is prevented and handed to Rust. Redirects, form posts and OAuth
|
||||
/// bounces are untouched, which is what keeps sign-in flows alive.
|
||||
fn interceptor_script(scopes: &[String]) -> String {
|
||||
let json = serde_json::to_string(scopes).unwrap_or_else(|_| "[]".into());
|
||||
format!(
|
||||
r#"(function () {{
|
||||
if (window.__workAppRouter) return;
|
||||
window.__workAppRouter = true;
|
||||
var SCOPES = {json};
|
||||
|
||||
function abs(href) {{ try {{ return new URL(href, document.baseURI).href; }} catch (e) {{ return null; }} }}
|
||||
function inScope(u) {{
|
||||
try {{
|
||||
var h = new URL(u).hostname.replace(/^www\./, '').toLowerCase();
|
||||
return SCOPES.some(function (s) {{
|
||||
s = String(s).replace(/^www\./, '').toLowerCase();
|
||||
return h === s || h.endsWith('.' + s);
|
||||
}});
|
||||
}} catch (e) {{ return false; }}
|
||||
}}
|
||||
function ask(u) {{
|
||||
try {{ window.location.href = '{scheme}:/?u=' + encodeURIComponent(u); }} catch (e) {{}}
|
||||
}}
|
||||
|
||||
document.addEventListener('click', function (e) {{
|
||||
if (e.defaultPrevented || e.button !== 0) return;
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
|
||||
if (!a) return;
|
||||
var href = a.getAttribute('href');
|
||||
if (!href || href.charAt(0) === '#') return;
|
||||
if (/^(javascript|blob|data):/i.test(href)) return;
|
||||
var u = abs(href);
|
||||
if (!u) return;
|
||||
|
||||
if (inScope(u)) {{
|
||||
// Our own host. A new-tab link has nowhere to go in a tabless app, so
|
||||
// it takes over this view rather than being swallowed.
|
||||
if (a.target === '_blank') {{ e.preventDefault(); window.location.href = u; }}
|
||||
return;
|
||||
}}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ask(u);
|
||||
}}, true);
|
||||
|
||||
var nativeOpen = window.open;
|
||||
window.open = function (url) {{
|
||||
if (!url) return null;
|
||||
var u = abs(url);
|
||||
if (!u) return null;
|
||||
if (inScope(u)) {{ window.location.href = u; return null; }}
|
||||
ask(u);
|
||||
return null;
|
||||
}};
|
||||
void nativeOpen;
|
||||
}})();"#,
|
||||
json = json,
|
||||
scheme = ROUTE_SCHEME
|
||||
)
|
||||
}
|
||||
|
||||
/// Pulls the URL back out of a `workapp-route:/?u=…` sentinel.
|
||||
pub fn route_target(url: &Url) -> Option<String> {
|
||||
if url.scheme() != ROUTE_SCHEME {
|
||||
return None;
|
||||
}
|
||||
url.query_pairs()
|
||||
.find(|(k, _)| k == "u")
|
||||
.map(|(_, v)| v.to_string())
|
||||
}
|
||||
|
||||
/// Acts on a decision. Called off the navigation delegate, never on it.
|
||||
fn apply(handle: &AppHandle, from: &str, decision: Decision) {
|
||||
match decision {
|
||||
// Only an identity provider reaches here, and the click that produced
|
||||
// it was already cancelled — so this view has to be sent there.
|
||||
Decision::Stay => {}
|
||||
Decision::Switch { app_id, url } => {
|
||||
let _ = handle.emit("switch-app", SwitchEvent { app_id, url });
|
||||
}
|
||||
Decision::External { url } => {
|
||||
let _ = tauri_plugin_opener::open_url(url, None::<&str>);
|
||||
}
|
||||
}
|
||||
let _ = from;
|
||||
}
|
||||
|
||||
/// Handles a sentinel URL: decide, then act without blocking the delegate.
|
||||
fn handle_route(handle: &AppHandle, from: &str, target: String, scopes: Vec<AppScope>) {
|
||||
let handle = handle.clone();
|
||||
let from = from.to_string();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match routing::decide(&target, Some(&from), &scopes) {
|
||||
Decision::Stay => {
|
||||
// The click was cancelled to ask the question, so completing it
|
||||
// is now this side's job.
|
||||
if let Some(wv) = handle.get_webview(&label_for(&from)) {
|
||||
if let Ok(u) = Url::parse(&target) {
|
||||
let _ = wv.navigate(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
other => apply(&handle, &from, other),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds the child webview for one app and parks it in the stage rect.
|
||||
pub fn create(handle: &AppHandle, app: &App, cfg: &Config, stage: (f64, f64, f64, f64)) -> Result<(), String> {
|
||||
let window = handle
|
||||
.get_window("main")
|
||||
.ok_or_else(|| "main window is gone".to_string())?;
|
||||
|
||||
let url = Url::parse(&app.url).map_err(|e| format!("{}: {e}", app.url))?;
|
||||
let id = app.id.clone();
|
||||
let scopes = cfg.scopes();
|
||||
|
||||
let nav_handle = handle.clone();
|
||||
let nav_id = id.clone();
|
||||
let nav_scopes = scopes.clone();
|
||||
|
||||
let win_handle = handle.clone();
|
||||
let win_id = id.clone();
|
||||
let win_scopes = scopes.clone();
|
||||
|
||||
let builder = WebviewBuilder::new(label_for(&id), WebviewUrl::External(url))
|
||||
.user_agent(&app.ua())
|
||||
.initialization_script(interceptor_script(&app.scopes()))
|
||||
.on_navigation(move |url| {
|
||||
// The sentinel is a question, not a destination: answer it and
|
||||
// cancel. Everything else is allowed — a strict filter here would
|
||||
// break every OAuth redirect chain.
|
||||
if let Some(target) = route_target(url) {
|
||||
handle_route(&nav_handle, &nav_id, target, nav_scopes.clone());
|
||||
return false;
|
||||
}
|
||||
let _ = nav_handle.emit(
|
||||
"url-changed",
|
||||
UrlEvent { app_id: nav_id.clone(), url: url.to_string() },
|
||||
);
|
||||
true
|
||||
})
|
||||
.on_new_window(move |url, _features| {
|
||||
// Nothing may open a window of its own; the same rules apply.
|
||||
let decision = routing::decide(url.as_str(), Some(&win_id), &win_scopes);
|
||||
match decision {
|
||||
Decision::Stay => {
|
||||
if let Some(wv) = win_handle.get_webview(&label_for(&win_id)) {
|
||||
let _ = wv.navigate(url);
|
||||
}
|
||||
}
|
||||
other => apply(&win_handle, &win_id, other),
|
||||
}
|
||||
NewWindowResponse::Deny
|
||||
});
|
||||
|
||||
window
|
||||
.add_child(
|
||||
builder,
|
||||
LogicalPosition::new(stage.0, stage.1),
|
||||
LogicalSize::new(stage.2, stage.3),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Created hidden. `show` is what puts one on screen, so startup does not
|
||||
// flash every app in turn as they are built.
|
||||
if let Some(wv) = handle.get_webview(&label_for(&id)) {
|
||||
let _ = wv.hide();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shows one app and hides the rest, sizing it to the stage.
|
||||
pub fn show_only(handle: &AppHandle, app_id: Option<&str>, cfg: &Config, stage: (f64, f64, f64, f64)) {
|
||||
for app in &cfg.apps {
|
||||
let Some(wv) = handle.get_webview(&label_for(&app.id)) else { continue };
|
||||
if Some(app.id.as_str()) == app_id {
|
||||
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1));
|
||||
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
|
||||
let _ = wv.show();
|
||||
} else {
|
||||
let _ = wv.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-sizes whichever app is showing. Called on every layout change.
|
||||
pub fn set_stage(handle: &AppHandle, active: Option<&str>, stage: (f64, f64, f64, f64)) {
|
||||
let Some(id) = active else { return };
|
||||
let Some(wv) = handle.get_webview(&label_for(id)) else { return };
|
||||
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1));
|
||||
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
|
||||
}
|
||||
|
||||
/// Hides every app webview, so the shell can draw over the whole window.
|
||||
pub fn hide_all(handle: &AppHandle, cfg: &Config) {
|
||||
for app in &cfg.apps {
|
||||
if let Some(wv) = handle.get_webview(&label_for(&app.id)) {
|
||||
let _ = wv.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn destroy(handle: &AppHandle, app_id: &str) {
|
||||
if let Some(wv) = handle.get_webview(&label_for(app_id)) {
|
||||
let _ = wv.close();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn route_target_round_trips_a_url_with_a_query() {
|
||||
let u = Url::parse("workapp-route:/?u=https%3A%2F%2Fx.com%2Fa%3Fb%3D1%26c%3D2").unwrap();
|
||||
assert_eq!(route_target(&u).unwrap(), "https://x.com/a?b=1&c=2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_urls_are_not_sentinels() {
|
||||
let u = Url::parse("https://github.com/?u=x").unwrap();
|
||||
assert!(route_target(&u).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_script_carries_the_apps_own_scope() {
|
||||
let s = interceptor_script(&["mail.google.com".into()]);
|
||||
assert!(s.contains("mail.google.com"));
|
||||
assert!(s.contains("workapp-route:/?u="));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Work",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.vincent.workapp",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Work",
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"center": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true
|
||||
}
|
||||
],
|
||||
"security": { "csp": null }
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["app"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||