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
+16
@@ -3684,6 +3684,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.3"
|
||||
@@ -4926,6 +4941,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-window-state",
|
||||
"tokio",
|
||||
"url",
|
||||
"uuid",
|
||||
|
||||
@@ -22,11 +22,12 @@ serde_json = "1"
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
tauri-plugin-window-state = "2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6"
|
||||
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"] }
|
||||
block2 = "0.6"
|
||||
# Notifications are raised here rather than through the plugin, which offers no
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"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",
|
||||
"const": "opener:deny-reveal-item-in-dir",
|
||||
"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",
|
||||
"const": "opener:deny-reveal-item-in-dir",
|
||||
"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>,
|
||||
/// A login waiting on an answer: host, account, password. Held only until
|
||||
/// 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 active: Mutex<Option<String>>,
|
||||
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());
|
||||
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.
|
||||
state.unread.lock().unwrap().remove(&app_id);
|
||||
let _ = app.emit("unread-changed", unread_list(&state));
|
||||
@@ -312,6 +316,7 @@ pub fn add_app(
|
||||
user_agent: None,
|
||||
hidden: Vec::new(),
|
||||
icon: None,
|
||||
saved_account: None,
|
||||
zoom: 1.0,
|
||||
order,
|
||||
};
|
||||
@@ -667,6 +672,56 @@ pub fn set_zoom(app_id: String, zoom: f64, app: AppHandle, state: State<'_, AppS
|
||||
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.
|
||||
///
|
||||
/// `NSUserNotificationCenter` suppresses the banner whenever the posting
|
||||
@@ -798,7 +853,7 @@ pub fn reset_config(app: AppHandle, state: State<'_, AppState>) -> Result<Config
|
||||
#[tauri::command]
|
||||
pub fn save_password(state: State<'_, AppState>) -> Result<String, String> {
|
||||
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());
|
||||
};
|
||||
|
||||
@@ -815,9 +870,64 @@ pub fn save_password(state: State<'_, AppState>) -> Result<String, String> {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[tauri::command]
|
||||
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.
|
||||
#[serde(default)]
|
||||
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
|
||||
/// want the same size.
|
||||
#[serde(default = "default_zoom")]
|
||||
@@ -81,6 +85,9 @@ pub struct Settings {
|
||||
pub nav_collapsed: bool,
|
||||
#[serde(default = "default_theme")]
|
||||
pub theme: String,
|
||||
/// The app that was showing when the window last closed.
|
||||
#[serde(default)]
|
||||
pub last_app: Option<String>,
|
||||
}
|
||||
|
||||
fn default_theme() -> String {
|
||||
@@ -92,6 +99,7 @@ impl Default for Settings {
|
||||
Self {
|
||||
nav_collapsed: false,
|
||||
theme: default_theme(),
|
||||
last_app: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,6 +205,7 @@ pub fn seed() -> Config {
|
||||
user_agent: None,
|
||||
hidden: Vec::new(),
|
||||
icon: None,
|
||||
saved_account: None,
|
||||
zoom: 1.0,
|
||||
order,
|
||||
};
|
||||
|
||||
+50
-2
@@ -288,6 +288,7 @@
|
||||
/* ------------------------------------------------------- context menu */
|
||||
|
||||
var menu = null;
|
||||
var lastRightClicked = null;
|
||||
|
||||
function closeMenu() {
|
||||
if (menu) { menu.remove(); menu = null; }
|
||||
@@ -311,12 +312,18 @@
|
||||
menu.style.borderColor = '#334155';
|
||||
}
|
||||
|
||||
[
|
||||
var items = [
|
||||
['Hide this element', function () { hide(target); }],
|
||||
['Pick an element to hide…', startPicking],
|
||||
['Manage hidden elements…', function () { send('manage', { 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');
|
||||
b.textContent = item[0];
|
||||
b.style.cssText = 'padding:6px 10px;border-radius:6px;cursor:pointer;white-space:nowrap';
|
||||
@@ -339,6 +346,7 @@
|
||||
|
||||
document.addEventListener('contextmenu', function (e) {
|
||||
e.preventDefault();
|
||||
lastRightClicked = e.target;
|
||||
openMenu(e.clientX, e.clientY, e.target);
|
||||
}, true);
|
||||
|
||||
@@ -506,6 +514,46 @@
|
||||
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) {
|
||||
var form = e.target;
|
||||
if (!form || form.tagName !== 'FORM') return;
|
||||
|
||||
@@ -72,6 +72,8 @@ pub fn run() {
|
||||
"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_notification::init())
|
||||
.setup(|app| {
|
||||
@@ -115,9 +117,12 @@ pub fn run() {
|
||||
commands::set_nav_collapsed,
|
||||
commands::set_theme,
|
||||
commands::reset_config,
|
||||
commands::set_window_chrome,
|
||||
commands::chrome_height,
|
||||
commands::unread_counts,
|
||||
commands::focus_window,
|
||||
commands::save_password,
|
||||
commands::fill_password,
|
||||
commands::discard_password,
|
||||
commands::notification_click,
|
||||
commands::set_zoom,
|
||||
|
||||
@@ -271,13 +271,21 @@ fn handle_sentinel(
|
||||
let account = params.get("u").cloned().unwrap_or_default();
|
||||
let state = handle.state::<crate::commands::AppState>();
|
||||
*state.pending_password.lock().unwrap() =
|
||||
Some((host.clone(), account.clone(), pass.clone()));
|
||||
Some((from.clone(), host.clone(), account.clone(), pass.clone()));
|
||||
let _ = handle.emit(
|
||||
"password-offer",
|
||||
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" => {
|
||||
empty_cache(&handle, &from);
|
||||
}
|
||||
@@ -929,6 +937,7 @@ mod tests {
|
||||
user_agent: None,
|
||||
hidden: vec![".ad".into()],
|
||||
icon: None,
|
||||
saved_account: None,
|
||||
zoom: 1.0,
|
||||
order: 0,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user