Remember window and tab; fill saved passwords; match the title bar
The window's position and size survive a restart, and so does the app you were last in. Saving a password was only half of it - a saved password you cannot get back out is not saved in any useful sense. Right-click now offers "Fill saved password" wherever a page has a password field. Only on an explicit click, only for the host on screen, and only into that page's own fields, never on load. The value is set through the native setter and followed by input and change events, because assigning .value is invisible to React and Angular and the site would submit an empty field. The title bar is painted the nav's colour rather than its own grey, so the two read as one surface in either theme. Doing that revealed that the window's content had always run the full height underneath it - the opaque bar was hiding it - so the shell is now inset by the measured title bar height, or the nav lands on the traffic lights. The rail's unread badge is a fixed circle inside the button's bounds. Padding around a number made a pill at two digits, and hanging it off the corner put it under the rail's clip; past nine it reads "9+", with the figure itself in the expanded nav.
This commit is contained in:
Generated
+12
-2
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "work-app",
|
"name": "work-app",
|
||||||
"version": "0.1.0",
|
"version": "0.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "work-app",
|
"name": "work-app",
|
||||||
"version": "0.1.0",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0"
|
||||||
},
|
},
|
||||||
@@ -1680,6 +1681,15 @@
|
|||||||
"@tauri-apps/api": "^2.11.0"
|
"@tauri-apps/api": "^2.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-window-state": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-window-state/-/plugin-window-state-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+16
@@ -3684,6 +3684,21 @@ dependencies = [
|
|||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-window-state"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.11.3"
|
version = "2.11.3"
|
||||||
@@ -4926,6 +4941,7 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-notification",
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"tauri-plugin-window-state",
|
||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ serde_json = "1"
|
|||||||
tokio = { version = "1", features = ["time"] }
|
tokio = { version = "1", features = ["time"] }
|
||||||
url = "2"
|
url = "2"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
tauri-plugin-window-state = "2"
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
objc2 = "0.6"
|
objc2 = "0.6"
|
||||||
objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "WKWebViewConfiguration", "WKPreferences", "WKSnapshotConfiguration", "WKWebsiteDataStore", "WKWebsiteDataRecord", "block2"] }
|
objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "WKWebViewConfiguration", "WKPreferences", "WKSnapshotConfiguration", "WKWebsiteDataStore", "WKWebsiteDataRecord", "block2"] }
|
||||||
objc2-app-kit = { version = "0.3", features = ["NSImage", "NSBitmapImageRep", "NSImageRep", "NSGraphics"] }
|
objc2-app-kit = { version = "0.3", features = ["NSImage", "NSBitmapImageRep", "NSImageRep", "NSGraphics", "NSColor", "NSWindow", "NSResponder"] }
|
||||||
objc2-foundation = { version = "0.3", features = ["NSData", "NSString", "NSDictionary", "NSValue", "NSError", "NSGeometry", "NSSet", "NSDate", "NSArray"] }
|
objc2-foundation = { version = "0.3", features = ["NSData", "NSString", "NSDictionary", "NSValue", "NSError", "NSGeometry", "NSSet", "NSDate", "NSArray"] }
|
||||||
block2 = "0.6"
|
block2 = "0.6"
|
||||||
# Notifications are raised here rather than through the plugin, which offers no
|
# Notifications are raised here rather than through the plugin, which offers no
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"core:default",
|
"core:default",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
"notification:default"
|
"notification:default",
|
||||||
|
"window-state:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +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","notification:default"]}}
|
{"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","notification:default","window-state:default"]}}
|
||||||
@@ -2605,6 +2605,48 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "opener:deny-reveal-item-in-dir",
|
"const": "opener:deny-reveal-item-in-dir",
|
||||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "window-state:default",
|
||||||
|
"markdownDescription": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the filename command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "window-state:allow-filename",
|
||||||
|
"markdownDescription": "Enables the filename command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the restore_state command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "window-state:allow-restore-state",
|
||||||
|
"markdownDescription": "Enables the restore_state command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the save_window_state command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "window-state:allow-save-window-state",
|
||||||
|
"markdownDescription": "Enables the save_window_state command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the filename command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "window-state:deny-filename",
|
||||||
|
"markdownDescription": "Denies the filename command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the restore_state command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "window-state:deny-restore-state",
|
||||||
|
"markdownDescription": "Denies the restore_state command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the save_window_state command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "window-state:deny-save-window-state",
|
||||||
|
"markdownDescription": "Denies the save_window_state command without any pre-configured scope."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2605,6 +2605,48 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "opener:deny-reveal-item-in-dir",
|
"const": "opener:deny-reveal-item-in-dir",
|
||||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "window-state:default",
|
||||||
|
"markdownDescription": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the filename command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "window-state:allow-filename",
|
||||||
|
"markdownDescription": "Enables the filename command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the restore_state command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "window-state:allow-restore-state",
|
||||||
|
"markdownDescription": "Enables the restore_state command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the save_window_state command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "window-state:allow-save-window-state",
|
||||||
|
"markdownDescription": "Enables the save_window_state command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the filename command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "window-state:deny-filename",
|
||||||
|
"markdownDescription": "Denies the filename command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the restore_state command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "window-state:deny-restore-state",
|
||||||
|
"markdownDescription": "Denies the restore_state command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the save_window_state command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "window-state:deny-save-window-state",
|
||||||
|
"markdownDescription": "Denies the save_window_state command without any pre-configured scope."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
+112
-2
@@ -22,7 +22,7 @@ pub struct AppState {
|
|||||||
pub chrome: Mutex<f64>,
|
pub chrome: Mutex<f64>,
|
||||||
/// A login waiting on an answer: host, account, password. Held only until
|
/// A login waiting on an answer: host, account, password. Held only until
|
||||||
/// it is saved or declined, and never written anywhere but the Keychain.
|
/// it is saved or declined, and never written anywhere but the Keychain.
|
||||||
pub pending_password: Mutex<Option<(String, String, String)>>,
|
pub pending_password: Mutex<Option<(String, String, String, String)>>,
|
||||||
pub config: Mutex<Config>,
|
pub config: Mutex<Config>,
|
||||||
pub active: Mutex<Option<String>>,
|
pub active: Mutex<Option<String>>,
|
||||||
pub stage: Mutex<Stage>,
|
pub stage: Mutex<Stage>,
|
||||||
@@ -181,6 +181,10 @@ pub fn set_active(app_id: String, app: AppHandle, state: State<'_, AppState>) {
|
|||||||
*state.active.lock().unwrap() = Some(app_id.clone());
|
*state.active.lock().unwrap() = Some(app_id.clone());
|
||||||
webviews::show_only(&app, Some(&app_id), &cfg, stage);
|
webviews::show_only(&app, Some(&app_id), &cfg, stage);
|
||||||
|
|
||||||
|
// Remembered so the next launch opens where this one left off.
|
||||||
|
state.config.lock().unwrap().settings.last_app = Some(app_id.clone());
|
||||||
|
let _ = state.persist();
|
||||||
|
|
||||||
// Looking at an app is what clears its count. Nothing else does.
|
// Looking at an app is what clears its count. Nothing else does.
|
||||||
state.unread.lock().unwrap().remove(&app_id);
|
state.unread.lock().unwrap().remove(&app_id);
|
||||||
let _ = app.emit("unread-changed", unread_list(&state));
|
let _ = app.emit("unread-changed", unread_list(&state));
|
||||||
@@ -312,6 +316,7 @@ pub fn add_app(
|
|||||||
user_agent: None,
|
user_agent: None,
|
||||||
hidden: Vec::new(),
|
hidden: Vec::new(),
|
||||||
icon: None,
|
icon: None,
|
||||||
|
saved_account: None,
|
||||||
zoom: 1.0,
|
zoom: 1.0,
|
||||||
order,
|
order,
|
||||||
};
|
};
|
||||||
@@ -667,6 +672,56 @@ pub fn set_zoom(app_id: String, zoom: f64, app: AppHandle, state: State<'_, AppS
|
|||||||
Ok(state.cfg())
|
Ok(state.cfg())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Paints the title bar the same colour as the nav below it.
|
||||||
|
///
|
||||||
|
/// A standard title bar draws its own grey, which sits above a white sidebar as
|
||||||
|
/// a visible band of a different colour. Making it transparent lets the
|
||||||
|
/// window's own background show through instead, so the two read as one
|
||||||
|
/// surface — while keeping the real title bar, its traffic lights, and its
|
||||||
|
/// double-click and drag behaviour.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub fn paint_window_chrome(app: &AppHandle, dark: bool) {
|
||||||
|
use objc2::runtime::AnyObject;
|
||||||
|
use objc2_app_kit::NSColor;
|
||||||
|
|
||||||
|
let Some(wv) = app.get_webview_window("main") else { return };
|
||||||
|
let _ = wv.with_webview(move |platform| unsafe {
|
||||||
|
let view = platform.inner() as *mut AnyObject;
|
||||||
|
if view.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let window: *mut AnyObject = objc2::msg_send![view, window];
|
||||||
|
if window.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The nav's own colours: white, and slate-900.
|
||||||
|
let colour = if dark {
|
||||||
|
NSColor::colorWithSRGBRed_green_blue_alpha(0.059, 0.090, 0.165, 1.0)
|
||||||
|
} else {
|
||||||
|
NSColor::colorWithSRGBRed_green_blue_alpha(1.0, 1.0, 1.0, 1.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let _: () = objc2::msg_send![window, setTitlebarAppearsTransparent: true];
|
||||||
|
let _: () = objc2::msg_send![window, setBackgroundColor: &*colour];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
pub fn paint_window_chrome(_: &AppHandle, _: bool) {}
|
||||||
|
|
||||||
|
/// How tall the title bar is, so the shell can keep clear of it.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn chrome_height(app: AppHandle) -> f64 {
|
||||||
|
webviews::chrome_offset(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports the resolved light or dark, so the title bar can follow it.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_window_chrome(dark: bool, app: AppHandle) {
|
||||||
|
paint_window_chrome(&app, dark);
|
||||||
|
}
|
||||||
|
|
||||||
/// Makes macOS show a banner even when this app is the frontmost one.
|
/// Makes macOS show a banner even when this app is the frontmost one.
|
||||||
///
|
///
|
||||||
/// `NSUserNotificationCenter` suppresses the banner whenever the posting
|
/// `NSUserNotificationCenter` suppresses the banner whenever the posting
|
||||||
@@ -798,7 +853,7 @@ pub fn reset_config(app: AppHandle, state: State<'_, AppState>) -> Result<Config
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn save_password(state: State<'_, AppState>) -> Result<String, String> {
|
pub fn save_password(state: State<'_, AppState>) -> Result<String, String> {
|
||||||
let offer = state.pending_password.lock().unwrap().take();
|
let offer = state.pending_password.lock().unwrap().take();
|
||||||
let Some((host, account, password)) = offer else {
|
let Some((app_id, host, account, password)) = offer else {
|
||||||
return Err("nothing waiting to be saved".into());
|
return Err("nothing waiting to be saved".into());
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -815,9 +870,64 @@ pub fn save_password(state: State<'_, AppState>) -> Result<String, String> {
|
|||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
let _ = password;
|
let _ = password;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut cfg = state.config.lock().unwrap();
|
||||||
|
if let Some(a) = cfg.apps.iter_mut().find(|a| a.id == app_id) {
|
||||||
|
a.saved_account = Some(account);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.persist()?;
|
||||||
|
|
||||||
Ok(host)
|
Ok(host)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Puts a saved login back into the page that asked for it.
|
||||||
|
///
|
||||||
|
/// The other half of saving one, and the more exposed half: a password has to
|
||||||
|
/// leave the Keychain and enter a web page for this to be worth anything. So it
|
||||||
|
/// happens only on an explicit right-click, only for the host on screen, and
|
||||||
|
/// only into that page's own fields — never automatically on load, which would
|
||||||
|
/// hand credentials to whatever a page happened to render.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn fill_password(app_id: String, app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
|
||||||
|
fill_password_for(&app, &app_id, &state)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fill_password_for(
|
||||||
|
app: &AppHandle,
|
||||||
|
app_id: &str,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let cfg = state.cfg();
|
||||||
|
let target = cfg.app(app_id).ok_or_else(|| format!("no app {app_id}"))?;
|
||||||
|
let account = target
|
||||||
|
.saved_account
|
||||||
|
.clone()
|
||||||
|
.ok_or("no password saved for this app")?;
|
||||||
|
let host = config::default_scope(&target.url).ok_or("this app has no host")?;
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let password = {
|
||||||
|
let service = format!("Work — {host}");
|
||||||
|
let bytes = security_framework::passwords::get_generic_password(&service, &account)
|
||||||
|
.map_err(|_| "the Keychain has nothing saved for this site".to_string())?;
|
||||||
|
String::from_utf8_lossy(&bytes).into_owned()
|
||||||
|
};
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
let password = String::new();
|
||||||
|
|
||||||
|
let wv = app
|
||||||
|
.get_webview(&webviews::label_for(app_id))
|
||||||
|
.ok_or_else(|| format!("{app_id} has no webview"))?;
|
||||||
|
|
||||||
|
wv.eval(&format!(
|
||||||
|
"window.__workAppFill && window.__workAppFill({}, {})",
|
||||||
|
serde_json::to_string(&account).map_err(|e| e.to_string())?,
|
||||||
|
serde_json::to_string(&password).map_err(|e| e.to_string())?,
|
||||||
|
))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Drops the offered login without saving it.
|
/// Drops the offered login without saving it.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn discard_password(state: State<'_, AppState>) {
|
pub fn discard_password(state: State<'_, AppState>) {
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ pub struct App {
|
|||||||
/// from the moment it opens rather than once every page has loaded.
|
/// from the moment it opens rather than once every page has loaded.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub icon: Option<String>,
|
pub icon: Option<String>,
|
||||||
|
/// The username a password was saved under. Not the password — that is in
|
||||||
|
/// the Keychain — but without it the Keychain cannot be asked for the item.
|
||||||
|
#[serde(default)]
|
||||||
|
pub saved_account: Option<String>,
|
||||||
/// Page zoom, remembered per app: a dense ERP and a mail client do not
|
/// Page zoom, remembered per app: a dense ERP and a mail client do not
|
||||||
/// want the same size.
|
/// want the same size.
|
||||||
#[serde(default = "default_zoom")]
|
#[serde(default = "default_zoom")]
|
||||||
@@ -81,6 +85,9 @@ pub struct Settings {
|
|||||||
pub nav_collapsed: bool,
|
pub nav_collapsed: bool,
|
||||||
#[serde(default = "default_theme")]
|
#[serde(default = "default_theme")]
|
||||||
pub theme: String,
|
pub theme: String,
|
||||||
|
/// The app that was showing when the window last closed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_app: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_theme() -> String {
|
fn default_theme() -> String {
|
||||||
@@ -92,6 +99,7 @@ impl Default for Settings {
|
|||||||
Self {
|
Self {
|
||||||
nav_collapsed: false,
|
nav_collapsed: false,
|
||||||
theme: default_theme(),
|
theme: default_theme(),
|
||||||
|
last_app: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,6 +205,7 @@ pub fn seed() -> Config {
|
|||||||
user_agent: None,
|
user_agent: None,
|
||||||
hidden: Vec::new(),
|
hidden: Vec::new(),
|
||||||
icon: None,
|
icon: None,
|
||||||
|
saved_account: None,
|
||||||
zoom: 1.0,
|
zoom: 1.0,
|
||||||
order,
|
order,
|
||||||
};
|
};
|
||||||
|
|||||||
+50
-2
@@ -288,6 +288,7 @@
|
|||||||
/* ------------------------------------------------------- context menu */
|
/* ------------------------------------------------------- context menu */
|
||||||
|
|
||||||
var menu = null;
|
var menu = null;
|
||||||
|
var lastRightClicked = null;
|
||||||
|
|
||||||
function closeMenu() {
|
function closeMenu() {
|
||||||
if (menu) { menu.remove(); menu = null; }
|
if (menu) { menu.remove(); menu = null; }
|
||||||
@@ -311,12 +312,18 @@
|
|||||||
menu.style.borderColor = '#334155';
|
menu.style.borderColor = '#334155';
|
||||||
}
|
}
|
||||||
|
|
||||||
[
|
var items = [
|
||||||
['Hide this element', function () { hide(target); }],
|
['Hide this element', function () { hide(target); }],
|
||||||
['Pick an element to hide…', startPicking],
|
['Pick an element to hide…', startPicking],
|
||||||
['Manage hidden elements…', function () { send('manage', { x: '1' }); }],
|
['Manage hidden elements…', function () { send('manage', { x: '1' }); }],
|
||||||
['Empty cache and reload', function () { send('emptycache', { x: '1' }); }]
|
['Empty cache and reload', function () { send('emptycache', { x: '1' }); }]
|
||||||
].forEach(function (item) {
|
];
|
||||||
|
|
||||||
|
if (document.querySelector('input[type="password"]')) {
|
||||||
|
items.unshift(['Fill saved password', function () { send('fillpw', { x: '1' }); }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.forEach(function (item) {
|
||||||
var b = document.createElement('div');
|
var b = document.createElement('div');
|
||||||
b.textContent = item[0];
|
b.textContent = item[0];
|
||||||
b.style.cssText = 'padding:6px 10px;border-radius:6px;cursor:pointer;white-space:nowrap';
|
b.style.cssText = 'padding:6px 10px;border-radius:6px;cursor:pointer;white-space:nowrap';
|
||||||
@@ -339,6 +346,7 @@
|
|||||||
|
|
||||||
document.addEventListener('contextmenu', function (e) {
|
document.addEventListener('contextmenu', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
lastRightClicked = e.target;
|
||||||
openMenu(e.clientX, e.clientY, e.target);
|
openMenu(e.clientX, e.clientY, e.target);
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
@@ -506,6 +514,46 @@
|
|||||||
return { user: user, pass: pw.value };
|
return { user: user, pass: pw.value };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Called from Rust after you ask for it, never on its own. Fills the form
|
||||||
|
around the password field you right-clicked, or the first one on the page. */
|
||||||
|
window.__workAppFill = function (account, password) {
|
||||||
|
var pw = (lastRightClicked && lastRightClicked.closest
|
||||||
|
? lastRightClicked.closest('form')
|
||||||
|
: null);
|
||||||
|
var form = pw || document.querySelector('form input[type="password"]');
|
||||||
|
if (form && form.tagName === 'INPUT') form = form.form;
|
||||||
|
if (!form) form = document;
|
||||||
|
|
||||||
|
var field = form.querySelector('input[type="password"]');
|
||||||
|
if (!field) return false;
|
||||||
|
|
||||||
|
var user = null;
|
||||||
|
var all = form.querySelectorAll('input');
|
||||||
|
for (var i = 0; i < all.length; i++) {
|
||||||
|
if (all[i] === field) break;
|
||||||
|
var t = (all[i].type || '').toLowerCase();
|
||||||
|
if (t === 'text' || t === 'email' || t === 'tel') user = all[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fill(el, value) {
|
||||||
|
if (!el) return;
|
||||||
|
/* Assigning .value directly is invisible to React and Angular, which
|
||||||
|
track their own copy — the site would submit an empty field. This sets
|
||||||
|
it the way a keystroke would. */
|
||||||
|
var proto = Object.getPrototypeOf(el);
|
||||||
|
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
|
||||||
|
if (setter && setter.set) setter.set.call(el, value);
|
||||||
|
else el.value = value;
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account) fill(user, account);
|
||||||
|
fill(field, password);
|
||||||
|
field.focus();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener('submit', function (e) {
|
document.addEventListener('submit', function (e) {
|
||||||
var form = e.target;
|
var form = e.target;
|
||||||
if (!form || form.tagName !== 'FORM') return;
|
if (!form || form.tagName !== 'FORM') return;
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ pub fn run() {
|
|||||||
"reload" => commands::reload_active(app),
|
"reload" => commands::reload_active(app),
|
||||||
_ => {}
|
_ => {}
|
||||||
})
|
})
|
||||||
|
// Remembers where the window was and how big, across launches.
|
||||||
|
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
@@ -115,9 +117,12 @@ pub fn run() {
|
|||||||
commands::set_nav_collapsed,
|
commands::set_nav_collapsed,
|
||||||
commands::set_theme,
|
commands::set_theme,
|
||||||
commands::reset_config,
|
commands::reset_config,
|
||||||
|
commands::set_window_chrome,
|
||||||
|
commands::chrome_height,
|
||||||
commands::unread_counts,
|
commands::unread_counts,
|
||||||
commands::focus_window,
|
commands::focus_window,
|
||||||
commands::save_password,
|
commands::save_password,
|
||||||
|
commands::fill_password,
|
||||||
commands::discard_password,
|
commands::discard_password,
|
||||||
commands::notification_click,
|
commands::notification_click,
|
||||||
commands::set_zoom,
|
commands::set_zoom,
|
||||||
|
|||||||
@@ -271,13 +271,21 @@ fn handle_sentinel(
|
|||||||
let account = params.get("u").cloned().unwrap_or_default();
|
let account = params.get("u").cloned().unwrap_or_default();
|
||||||
let state = handle.state::<crate::commands::AppState>();
|
let state = handle.state::<crate::commands::AppState>();
|
||||||
*state.pending_password.lock().unwrap() =
|
*state.pending_password.lock().unwrap() =
|
||||||
Some((host.clone(), account.clone(), pass.clone()));
|
Some((from.clone(), host.clone(), account.clone(), pass.clone()));
|
||||||
let _ = handle.emit(
|
let _ = handle.emit(
|
||||||
"password-offer",
|
"password-offer",
|
||||||
PasswordOffer { app_id: from.clone(), host: host.clone(), account },
|
PasswordOffer { app_id: from.clone(), host: host.clone(), account },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Asked for by hand, from the page's own right-click menu.
|
||||||
|
"fillpw" => {
|
||||||
|
let state = handle.state::<crate::commands::AppState>();
|
||||||
|
if let Err(e) = crate::commands::fill_password_for(&handle, &from, &state) {
|
||||||
|
eprintln!("could not fill a password: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
"emptycache" => {
|
"emptycache" => {
|
||||||
empty_cache(&handle, &from);
|
empty_cache(&handle, &from);
|
||||||
}
|
}
|
||||||
@@ -929,6 +937,7 @@ mod tests {
|
|||||||
user_agent: None,
|
user_agent: None,
|
||||||
hidden: vec![".ad".into()],
|
hidden: vec![".ad".into()],
|
||||||
icon: None,
|
icon: None,
|
||||||
|
saved_account: None,
|
||||||
zoom: 1.0,
|
zoom: 1.0,
|
||||||
order: 0,
|
order: 0,
|
||||||
};
|
};
|
||||||
|
|||||||
+27
-3
@@ -20,9 +20,13 @@ export default function App() {
|
|||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [focusHidden, setFocusHidden] = useState<string | null>(null);
|
const [focusHidden, setFocusHidden] = useState<string | null>(null);
|
||||||
const [theme, setTheme] = useAppearance("system");
|
const [theme, setTheme, dark] = useAppearance("system");
|
||||||
const [unread, setUnread] = useState<Record<string, number>>({});
|
const [unread, setUnread] = useState<Record<string, number>>({});
|
||||||
const [backdrop, setBackdrop] = useState<string | null>(null);
|
const [backdrop, setBackdrop] = useState<string | null>(null);
|
||||||
|
/* The window's content runs the full height, underneath the title bar — the
|
||||||
|
opaque bar was only hiding that. With the bar painted the nav's colour, the
|
||||||
|
shell has to keep clear of it or the nav lands on the traffic lights. */
|
||||||
|
const [chrome, setChrome] = useState(0);
|
||||||
const [offer, setOffer] = useState<PasswordOffer | null>(null);
|
const [offer, setOffer] = useState<PasswordOffer | null>(null);
|
||||||
const [saved, setSaved] = useState<string | null>(null);
|
const [saved, setSaved] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -36,12 +40,18 @@ export default function App() {
|
|||||||
const collapsed = config?.settings.navCollapsed ?? false;
|
const collapsed = config?.settings.navCollapsed ?? false;
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void api.chromeHeight().then(setChrome);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getConfig().then((c) => {
|
api.getConfig().then((c) => {
|
||||||
setConfig(c);
|
setConfig(c);
|
||||||
setTheme(c.settings.theme);
|
setTheme(c.settings.theme);
|
||||||
|
// Back where you left off, if that app still exists.
|
||||||
|
const remembered = c.apps.find((a) => a.id === c.settings.lastApp);
|
||||||
const first = [...c.apps].sort((a, b) => a.order - b.order)[0];
|
const first = [...c.apps].sort((a, b) => a.order - b.order)[0];
|
||||||
setActiveId(first?.id ?? null);
|
setActiveId(remembered?.id ?? first?.id ?? null);
|
||||||
});
|
});
|
||||||
}, [setTheme]);
|
}, [setTheme]);
|
||||||
|
|
||||||
@@ -92,6 +102,12 @@ export default function App() {
|
|||||||
if (activeId) void api.setActive(activeId);
|
if (activeId) void api.setActive(activeId);
|
||||||
}, [activeId]);
|
}, [activeId]);
|
||||||
|
|
||||||
|
// The title bar is painted the nav's colour, so it has to follow whatever
|
||||||
|
// "system" resolved to — not just an explicit Light or Dark choice.
|
||||||
|
useEffect(() => {
|
||||||
|
void api.setWindowChrome(dark);
|
||||||
|
}, [dark]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlisten = [
|
const unlisten = [
|
||||||
// A link in one app that points at another: Rust decided, the shell moves.
|
// A link in one app that points at another: Rust decided, the shell moves.
|
||||||
@@ -179,7 +195,13 @@ export default function App() {
|
|||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen">
|
/* The inset is the nav's own colour, so the title bar above it and the
|
||||||
|
sidebar below read as one surface. */
|
||||||
|
<div
|
||||||
|
className="flex h-screen flex-col bg-white dark:bg-slate-900"
|
||||||
|
style={{ paddingTop: chrome }}
|
||||||
|
>
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
<Nav
|
<Nav
|
||||||
config={config}
|
config={config}
|
||||||
activeId={activeId}
|
activeId={activeId}
|
||||||
@@ -221,6 +243,8 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
{offer && (
|
{offer && (
|
||||||
<Dialog
|
<Dialog
|
||||||
title="Save this password?"
|
title="Save this password?"
|
||||||
|
|||||||
@@ -61,3 +61,6 @@ export const stageSnapshot = () => invoke<string | null>("stage_snapshot");
|
|||||||
export const savePassword = () => invoke<string>("save_password");
|
export const savePassword = () => invoke<string>("save_password");
|
||||||
export const discardPassword = () => invoke<void>("discard_password");
|
export const discardPassword = () => invoke<void>("discard_password");
|
||||||
export const resetConfig = () => invoke<Config>("reset_config");
|
export const resetConfig = () => invoke<Config>("reset_config");
|
||||||
|
export const setWindowChrome = (dark: boolean) =>
|
||||||
|
invoke<void>("set_window_chrome", { dark });
|
||||||
|
export const chromeHeight = () => invoke<number>("chrome_height");
|
||||||
|
|||||||
@@ -105,12 +105,17 @@ export default function Nav({
|
|||||||
{active && <span className="absolute left-0 h-5 w-[3px] rounded-full bg-sky-500" />}
|
{active && <span className="absolute left-0 h-5 w-[3px] rounded-full bg-sky-500" />}
|
||||||
<Favicon icon={app.icon} name={app.name} size={20} />
|
<Favicon icon={app.icon} name={app.name} size={20} />
|
||||||
{(unread[app.id] ?? 0) > 0 && (
|
{(unread[app.id] ?? 0) > 0 && (
|
||||||
|
/* A fixed circle, inside the button's own bounds. Padding around a
|
||||||
|
number makes a pill as soon as there are two digits, and hanging
|
||||||
|
it off the corner puts it under the rail's clip. Anything past
|
||||||
|
nine reads as "9+" — the exact figure is in the expanded nav. */
|
||||||
<span
|
<span
|
||||||
title={`${unread[app.id]} new since you last looked`}
|
title={`${unread[app.id]} new since you last looked`}
|
||||||
className="absolute -right-0.5 -top-0.5 min-w-[15px] rounded-full bg-sky-500
|
className="absolute right-0 top-0 grid size-[15px] place-items-center
|
||||||
px-1 text-center font-mono text-[9px] font-semibold text-white"
|
rounded-full bg-sky-500 font-mono text-[9px] font-semibold
|
||||||
|
leading-none text-white"
|
||||||
>
|
>
|
||||||
{unread[app.id] > 99 ? "99+" : unread[app.id]}
|
{unread[app.id] > 9 ? "9+" : unread[app.id]}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ export type Theme = "system" | "light" | "dark";
|
|||||||
*/
|
*/
|
||||||
export function useAppearance(initial: Theme) {
|
export function useAppearance(initial: Theme) {
|
||||||
const [theme, setTheme] = useState<Theme>(initial);
|
const [theme, setTheme] = useState<Theme>(initial);
|
||||||
|
/** What "system" actually resolved to — the title bar has to follow it too. */
|
||||||
|
const [dark, setDark] = useState(false);
|
||||||
|
|
||||||
useEffect(() => setTheme(initial), [initial]);
|
useEffect(() => setTheme(initial), [initial]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
const dark = theme === "dark" || (theme === "system" && media.matches);
|
const isDark = theme === "dark" || (theme === "system" && media.matches);
|
||||||
document.documentElement.classList.toggle("dark", dark);
|
document.documentElement.classList.toggle("dark", isDark);
|
||||||
|
setDark(isDark);
|
||||||
};
|
};
|
||||||
apply();
|
apply();
|
||||||
if (theme !== "system") return;
|
if (theme !== "system") return;
|
||||||
@@ -25,5 +28,5 @@ export function useAppearance(initial: Theme) {
|
|||||||
return () => media.removeEventListener("change", apply);
|
return () => media.removeEventListener("change", apply);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
return [theme, setTheme] as const;
|
return [theme, setTheme, dark] as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface Group {
|
|||||||
export interface Settings {
|
export interface Settings {
|
||||||
navCollapsed: boolean;
|
navCollapsed: boolean;
|
||||||
theme: "system" | "light" | "dark";
|
theme: "system" | "light" | "dark";
|
||||||
|
lastApp: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
|
|||||||
Reference in New Issue
Block a user