All tests run on an 8-year-old MacBook Air. All results from shipping 7 Mac apps as a solo developer. No sponsored opinion.
HiyokoAutoSync has a Mini-Dash — a compact menubar widget showing sync status, device connection, and quick actions. It lives separately from the main window and is always one click away. Here's the pattern.
Why a Mini-Dash
The main app window has full controls. The mini-dash has just the essentials:
- Is the device connected?
- Is sync running?
- Last sync time
- One-click sync trigger
Users who set up sync and mostly let it run don't want to open the full app every time. The mini-dash is for them. It's the difference between a tool that stays out of your way and one that doesn't.
Two-Window Tauri Setup
tauri::Builder::default()
.setup(|app| {
// Main window
let _main = WebviewWindowBuilder::new(
app,
"main",
WebviewUrl::App("index.html".into())
)
.title("HiyokoAutoSync")
.inner_size(800.0, 600.0)
.visible(false)
.build()?;
// Mini-dash window
let _mini = WebviewWindowBuilder::new(
app,
"mini",
WebviewUrl::App("mini.html".into())
)
.title("HiyokoAutoSync Mini")
.inner_size(320.0, 200.0)
.always_on_top(true)
.decorations(false)
.resizable(false)
.visible(false)
.build()?;
Ok(())
})
Two separate windows, separate HTML entry points, same Rust backend. The key settings for the mini-dash:
| Setting | Value | Reason |
|---|---|---|
| always_on_top | true | stays visible over other windows |
| decorations | false | no titlebar — pure content |
| resizable | false | fixed compact size |
| visible | false | hidden until tray click |
Positioning the Mini-Dash
A mini-dash that appears at a random position feels broken. Use tauri-plugin-positioner to snap it below the tray icon:
# Cargo.toml
tauri-plugin-positioner = { version = "2", features = ["tray-icon"] }
fn toggle_mini_dash(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("mini") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.move_window(Position::TrayCenter);
let _ = window.show();
let _ = window.set_focus();
}
}
}
Without this, always_on_top windows tend to appear wherever they were last positioned — which is rarely where the user expects.
Shared State Between Windows
Both windows talk to the same Rust backend. Broadcast events to all windows at once:
// Broadcast to all windows
app_handle.emit("sync-status-changed", &status).ok();
// Or target a specific window
app_handle
.get_webview_window("mini")
.unwrap()
.emit("sync-status-changed", &status)
.ok();
The mini-dash listens for status events and updates its compact UI. The main window does the same with its full UI. One event, two listeners — no duplication.
Compact UI Design
The mini-dash is 320x200px. Every element earns its space:
export function MiniDash() {
const [status, setStatus] = useState<SyncStatus>()
useEffect(() => {
listen<SyncStatus>('sync-status-changed', (e) => {
setStatus(e.payload)
})
}, [])
return (
<div className="p-3 space-y-2">
<DeviceIndicator connected={status?.deviceConnected} />
<SyncStatusLine status={status?.syncState} />
<LastSyncTime time={status?.lastSync} />
<button onClick={() => invoke('trigger_sync')}>
Sync Now
</button>
</div>
)
}
Four elements. That's the whole UI. Resist the urge to add more — the value of a mini-dash is what it leaves out.
Tray Icon Integration
Left click → mini-dash. Right click → context menu with full options.
TrayIconBuilder::new()
.on_tray_icon_event(|tray, event| {
// Pass event to positioner first
tauri_plugin_positioner::on_tray_event(tray.app_handle(), &event);
match event {
TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} => {
toggle_mini_dash(tray.app_handle());
}
_ => {}
}
})
.menu(&menu) // Right-click: "Open Main Window", "Quit"
.show_menu_on_left_click(false)
.build(app)?;
NOTE: tauri_plugin_positioner::on_tray_event() を呼ぶのを忘れると、Position::TrayCenter が正しく動きません。
If this was useful, a ❤️ helps more than you'd think!
HiyokoAutoSync → https://hiyokomtp.lemonsqueezy.com/checkout/buy/20c922f1-ca45-4f77-aeb2-04e34aad2fb4
X → @hiyoyok
Top comments (1)
TL;DR: