Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ All Rust source lives in `src-tauri/src/`:

| File | Responsibility |
|------|---------------|
| `main.rs` | Entry point (4 lines — just calls `lib::run()`) |
| `main.rs` | Entry point — CLI flags, then `lib::run()` |
| `lib.rs` | Application setup: Tauri builder, embedded server, tray icon, config, window management |
| `manager.rs` | Module process manager: discovery, start/stop, crash recovery, tray menu updates |
| `dirs.rs` | Platform-specific paths for config, data, logs, runtime |
| `profile.rs` | `--profile` / `AW_PROFILE` resolution (same rule as aw-qt and aw-server-rust) |
| `logging.rs` | Log configuration with `fern`, rotation at 32 MB |

The aw-webui frontend is a git submodule at `aw-webui/`, built separately and served via WebView.
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ aw-tauri reads its config from a TOML file:
| macOS | `~/Library/Application Support/activitywatch/aw-tauri/config.toml` |
| Windows | `%APPDATA%\activitywatch\aw-tauri\config.toml` |

Named profiles (`--profile research`) isolate config, data, logs and the
single-instance lock under a sibling appname (`activitywatch-research` instead
of `activitywatch`). `default` and `testing` keep the paths above so existing
installs are not orphaned. `--testing` is an alias for `--profile testing`.
Spawned modules inherit `AW_PROFILE`. Custom profiles take `port` from their
own config (or `--port`); two instances cannot share 5600.

A default config is generated on first run. Example:

```toml
Expand Down Expand Up @@ -106,6 +113,7 @@ Log rotation happens automatically at 32 MB, keeping the 5 most recent rotated l
**Environment variables:**
- `AW_DEBUG=1` — Enable debug-level logging
- `AW_TRACE=1` — Enable trace-level logging (very verbose)
- `AW_PROFILE=<name>` — Fallback profile when `--profile` is not passed. The launcher also *exports* this so spawned modules inherit it.

## Architecture

Expand Down Expand Up @@ -148,6 +156,7 @@ aw-tauri/
│ │ ├── manager.rs # Module manager: discovery, lifecycle, crash recovery
│ │ ├── autostart.rs # Start-at-login: OS registration kept in sync with config
│ │ ├── dirs.rs # Platform-specific directory resolution
│ │ ├── profile.rs # --profile / AW_PROFILE resolution
│ │ └── logging.rs # Log setup with fern, rotation at 32 MB
│ ├── build.rs # Build script — requires AW_WEBUI_DIR env var
│ ├── Cargo.toml # Rust dependencies
Expand Down
60 changes: 46 additions & 14 deletions src-tauri/src/dirs.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,34 @@
//! Directory management for ActivityWatch Tauri
//!
//! Supported platforms: Windows, Linux, macOS, Android
//!
//! Isolation is at the platformdirs appname root. `default` and `testing`
//! keep the bare `activitywatch` name so existing installs are not orphaned;
//! any other profile (`research`, …) gets a sibling root `activitywatch-<p>`.
//! Module segments (`aw-tauri`, …) do not change.

use std::fs;
use std::path::PathBuf;

use crate::profile::{current_profile, DEFAULT_PROFILE, TESTING_PROFILE};

/// Platform "appname" root for the current profile.
#[cfg(not(target_os = "android"))]
fn appname() -> String {
appname_for(&current_profile())
}

/// `default` and `testing` keep the legacy bare root — existing installs must
/// not be orphaned. Any other profile gets its own sibling root.
#[cfg(not(target_os = "android"))]
fn appname_for(profile: &str) -> String {
if profile == DEFAULT_PROFILE || profile == TESTING_PROFILE {
"activitywatch".to_string()
} else {
format!("activitywatch-{profile}")
}
}

#[cfg(target_os = "android")]
use std::sync::Mutex;

Expand All @@ -21,7 +45,7 @@ lazy_static! {
pub fn get_config_dir() -> Result<PathBuf, ()> {
let dir = dirs::config_dir()
.ok_or(())?
.join("activitywatch")
.join(appname())
.join("aw-tauri");
fs::create_dir_all(&dir).expect("Unable to create config dir");
Ok(dir)
Expand All @@ -35,10 +59,7 @@ pub fn get_config_dir() -> Result<PathBuf, ()> {
#[cfg(not(target_os = "android"))]
#[allow(dead_code)]
pub fn get_data_dir() -> Result<PathBuf, ()> {
let dir = dirs::data_dir()
.ok_or(())?
.join("activitywatch")
.join("aw-tauri");
let dir = dirs::data_dir().ok_or(())?.join(appname()).join("aw-tauri");
fs::create_dir_all(&dir).expect("Unable to create data dir");
Ok(dir)
}
Expand All @@ -56,7 +77,7 @@ pub fn get_log_dir() -> Result<PathBuf, ()> {
// Linux uses cache dir for logs
let dir = dirs::cache_dir()
.ok_or(())?
.join("activitywatch")
.join(appname())
.join("aw-tauri")
.join("log");
fs::create_dir_all(&dir).expect("Unable to create log dir");
Expand All @@ -65,10 +86,10 @@ pub fn get_log_dir() -> Result<PathBuf, ()> {

#[cfg(target_os = "windows")]
pub fn get_log_dir() -> Result<PathBuf, ()> {
// Windows: %LOCALAPPDATA%\activitywatch\Logs\aw-tauri
// Windows: %LOCALAPPDATA%\<appname>\Logs\aw-tauri
let dir = dirs::data_local_dir()
.ok_or(())?
.join("activitywatch")
.join(appname())
.join("Logs")
.join("aw-tauri");
fs::create_dir_all(&dir).expect("Unable to create log dir");
Expand All @@ -81,12 +102,12 @@ pub fn get_log_dir() -> Result<PathBuf, ()> {
not(target_os = "windows")
))]
pub fn get_log_dir() -> Result<PathBuf, ()> {
// macOS: ~/Library/Logs/activitywatch/aw-tauri
// macOS: ~/Library/Logs/<appname>/aw-tauri
let dir = dirs::home_dir()
.ok_or(())?
.join("Library")
.join("Logs")
.join("activitywatch")
.join(appname())
.join("aw-tauri");
fs::create_dir_all(&dir).expect("Unable to create log dir");
Ok(dir)
Expand All @@ -113,17 +134,15 @@ pub fn get_log_path() -> PathBuf {
pub fn get_runtime_dir() -> PathBuf {
// Linux: use XDG_RUNTIME_DIR or fallback to cache dir
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
let dir = PathBuf::from(runtime_dir)
.join("activitywatch")
.join("aw-tauri");
let dir = PathBuf::from(runtime_dir).join(appname()).join("aw-tauri");
if fs::create_dir_all(&dir).is_ok() {
return dir;
}
}
// Fallback to cache dir
let dir = dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("activitywatch")
.join(appname())
.join("aw-tauri");
let _ = fs::create_dir_all(&dir);
dir
Expand Down Expand Up @@ -265,4 +284,17 @@ mod tests {
assert!(log_path.parent().unwrap().exists());
}
}

#[test]
#[cfg(not(target_os = "android"))]
fn test_appname_root_isolation() {
// default and testing keep the legacy bare root — existing installs
// must not be orphaned by this change.
assert_eq!(appname_for("default"), "activitywatch");
assert_eq!(appname_for("testing"), "activitywatch");

// any other profile gets its own sibling root
assert_eq!(appname_for("research"), "activitywatch-research");
assert_eq!(appname_for("my-profile"), "activitywatch-my-profile");
}
}
123 changes: 95 additions & 28 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,33 @@ mod logging;
mod manager;
mod mini;
mod module_alert_ui;
mod profile;
mod updater_ui;

pub use profile::{export_profile, is_testing, resolve_profile, DEFAULT_PROFILE, TESTING_PROFILE};

/// CLI arguments passed from main()
#[derive(Debug, Default)]
#[derive(Debug)]
pub struct CliArgs {
pub testing: bool,
pub verbose: bool,
pub port: Option<u16>,
pub daemon: bool,
pub mini: bool,
pub profile: String,
}

impl Default for CliArgs {
fn default() -> Self {
Self {
testing: false,
verbose: false,
port: None,
daemon: false,
mini: false,
profile: DEFAULT_PROFILE.to_string(),
}
}
}

static CLI_ARGS: OnceLock<CliArgs> = OnceLock::new();
Expand All @@ -60,6 +77,59 @@ fn get_cli_args() -> &'static CliArgs {
CLI_ARGS.get_or_init(CliArgs::default)
}

fn warn_if_custom_profile_shares_default_port(profile: &str, port: u16) {
if !profile::is_default(profile) && !profile::is_testing(profile) && port == 5600 {
warn!(
"profile '{profile}' is using port 5600; set `port` in its config or pass --port so it can run alongside the default instance"
);
}
}

fn log_profile_startup(profile: &str, port: u16) {
if profile::is_testing(profile) {
info!("Running in testing mode (port {port})");
} else if !profile::is_default(profile) {
info!("Running with profile '{profile}' (port {port})");
}
warn_if_custom_profile_shares_default_port(profile, port);
}

/// Single-instance plugin keyed per profile so named instances can run next to
/// the default one. On Linux this is a D-Bus name suffix; on Windows/macOS the
/// plugin still keys off the bundle identifier, so named profiles share that
/// slot until the plugin grows an identifier override.
fn single_instance_plugin<R: tauri::Runtime>(profile: &str) -> tauri::plugin::TauriPlugin<R> {
let lock_name = profile::lockfile_name(profile);
let callback = move |_app: &tauri::AppHandle<R>, _args: Vec<String>, _cwd: String| {
let lock_path = get_runtime_path().join(&lock_name);
if let Some(parent) = lock_path.parent() {
if !parent.exists() {
create_dir_all(parent).expect("Failed to create runtime dir");
}
}
let _lock_file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&lock_path)
.expect("Failed to open lock file");
info!("Another instance is running, quitting!");
};

#[cfg(target_os = "linux")]
{
tauri_plugin_single_instance::Builder::new()
.callback(callback)
.dbus_id(profile::single_instance_dbus_id(profile))
.build()
}

#[cfg(not(target_os = "linux"))]
{
tauri_plugin_single_instance::init(callback)
}
}

use log::{error, info, trace, warn};
use tauri::{
menu::{Menu, MenuItem},
Expand Down Expand Up @@ -255,10 +325,11 @@ fn build_dashboard_url(port: u16, api_key: Option<&str>) -> Url {
}

pub fn listen_for_lockfile() {
thread::spawn(|| {
let lock_name = profile::lockfile_name(&get_cli_args().profile);
thread::spawn(move || {
let runtime_path = get_runtime_path();
loop {
let watcher = match SpecificFileWatcher::new(&runtime_path, "single_instance.lock") {
let watcher = match SpecificFileWatcher::new(&runtime_path, &lock_name) {
Ok(w) => w,
Err(e) => {
warn!("Failed to create file watcher: {}. Retrying in 2s...", e);
Expand All @@ -271,7 +342,7 @@ pub fn listen_for_lockfile() {
match watcher.wait_for_file() {
Ok(()) => {
log::info!("Lock file detected");
remove_file(get_runtime_path().join("single_instance.lock"))
remove_file(get_runtime_path().join(&lock_name))
.expect("Failed to remove lock file");
let app = &*get_app_handle().lock().expect("Failed to get app handle");
if let Some(window) = app.webview_windows().get("main") {
Expand Down Expand Up @@ -552,6 +623,7 @@ fn run_daemon() {
device_id,
};

log_profile_startup(&cli_args.profile, port);
info!("Starting aw-tauri in daemon mode on port {port}");

// Build Tokio runtime first so we can spawn Rocket before starting modules
Expand Down Expand Up @@ -649,9 +721,7 @@ pub(crate) fn prepare_aw_server(
asset_resolver: aw_server::endpoints::AssetResolver::new(asset_path_opt),
device_id,
};
if testing {
info!("Running in testing mode (port {})", port);
}
log_profile_startup(&cli_args.profile, port);
let dashboard_api_key = aw_config
.auth
.api_key
Expand Down Expand Up @@ -866,22 +936,20 @@ pub fn run() {
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_autostart::init(
MacosLauncher::AppleScript,
Some(vec![]),
// AppleScript login items silently drop extra arguments; LaunchAgent
// writes a plist with ProgramArguments so --profile survives relogin.
if profile::is_default(&cli_args.profile) {
MacosLauncher::AppleScript
} else {
MacosLauncher::LaunchAgent
Comment thread
TimeToBuildBob marked this conversation as resolved.
},
if profile::is_default(&cli_args.profile) {
Some(vec![])
} else {
Some(vec!["--profile", cli_args.profile.as_str()])
Comment thread
TimeToBuildBob marked this conversation as resolved.
},
))
.plugin(tauri_plugin_single_instance::init(|_app, _args, _cwd| {
let lock_path = get_runtime_path().join("single_instance.lock");
if !lock_path.parent().unwrap().exists() {
create_dir_all(lock_path.parent().unwrap()).expect("Failed to create runtime dir");
}
let _lock_file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(lock_path)
.expect("Failed to open lock file");
info!("Another instance is running, quitting!");
}))
.plugin(single_instance_plugin(&cli_args.profile))
// Serve the module-alert HTML with a valid Origin for the webview.
.register_uri_scheme_protocol(module_alert_ui::URI_SCHEME, |_ctx, _request| {
tauri::http::Response::builder()
Expand Down Expand Up @@ -956,9 +1024,7 @@ pub fn run() {
.show(|_| {});
panic!("Port {} is already in use", port);
}
if testing {
info!("Running in testing mode (port {})", port);
}
log_profile_startup(&cli_args.profile, port);
let dashboard_api_key = aw_config
.auth
.api_key
Expand All @@ -979,7 +1045,7 @@ pub fn run() {
"main",
tauri::WebviewUrl::External(dashboard_url),
)
.title("aw-tauri")
.title(profile::window_title(&cli_args.profile))
.inner_size(800.0, 600.0)
.visible(false)
.initialization_script(
Expand Down Expand Up @@ -1019,7 +1085,8 @@ pub fn run() {
.clone(),
)
.menu(&menu)
.show_menu_on_left_click(true);
.show_menu_on_left_click(true)
.tooltip(profile::tray_tooltip(&cli_args.profile));

#[cfg(target_os = "windows")]
let tray_builder = TrayIconBuilder::new()
Expand All @@ -1030,7 +1097,7 @@ pub fn run() {
)
.menu(&menu)
.show_menu_on_left_click(true)
.tooltip("ActivityWatch");
.tooltip(profile::tray_tooltip(&cli_args.profile));
let tray = tray_builder.build(app).expect("Failed to create tray");

init_tray_id(tray.id().clone());
Expand Down
Loading