diff --git a/AGENTS.md b/AGENTS.md index 4e41a51..d18644f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,8 +31,8 @@ - Emit logs through `log`/`oslog` with concise context fields so Console filtering remains effective. ## Architecture & Design Patterns -- **Core components:** `main.rs` (Cocoa setup, initialization, run loop), `app.rs` (main state with `TunnelManager` and status item wrapper), `config.rs` (TOML config load/manage), `menu.rs` (NSStatusItem + NSMenu creation and icon handling), `tunnel.rs` (SSH/port-forward lifecycle), `logger.rs` (oslog configuration). -- **Key patterns:** thread-safe global state via `OnceLock`, thread-safe Cocoa wrappers, Objective-C bridge class for menu callbacks, async threads for long-running tunnel commands. +- **Core components:** `main.rs` (Cocoa setup, initialization, run loop), `app.rs` (main state with `TunnelManager`, `CommandRunner`, and status item wrapper), `config.rs` (TOML config load/manage, script auto-discovery), `command.rs` (one-time command execution with silent/notify/terminal output modes), `menu.rs` (NSStatusItem + NSMenu creation and icon handling), `tunnel.rs` (SSH/port-forward lifecycle), `logger.rs` (oslog configuration). +- **Key patterns:** thread-safe global state via `OnceLock`, thread-safe Cocoa wrappers, Objective-C bridge class for menu callbacks, async threads for long-running tunnel commands, callback-based cross-platform dispatch in `CommandRunner`. - **Dependencies:** cocoa/objc2, core-foundation, log/oslog, libc, toml, serde; patched `objc` fork for compatibility. ## Tunnel Configuration @@ -41,6 +41,13 @@ - Supports any command-line tool that can be started/stopped; config uses `#[serde(default)]` on optional fields for backward compatibility. - Default seed tunnels: `example-ssh`, `k8s-example`, and `colima`. +## One-Time Commands +- `[commands]` config section for fire-and-forget commands (no kill_command needed). +- Each command defines `name`, `command`, `args`, and optional `output` mode (`"silent"` | `"notify"` | `"terminal"`). +- `CommandRunner` in `core/src/command.rs` dispatches execution based on output mode using platform-specific callbacks (notify and terminal). +- `scripts_dir` config option auto-discovers `*.sh` files and adds them as commands with `"notify"` output mode. +- Menu order: Tunnels → Commands → Scheduled Tasks → system items. + ## Configuration Management - If config loading fails, fall back to hardcoded defaults. - `path` config entry controls PATH used for child processes; defaults include Homebrew locations. diff --git a/Cargo.lock b/Cargo.lock index aced43b..00f1817 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,9 +101,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "bitflags" @@ -113,11 +113,11 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.8.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -131,9 +131,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "cairo-rs" @@ -141,7 +141,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.11.0", "cairo-sys-rs", "glib", "libc", @@ -162,10 +162,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.10" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13208fcbb66eaeffe09b99fffbe1af420f00a7b35aa99ad683dfc1aa76145229" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ + "find-msvc-tools", "shlex", ] @@ -181,9 +182,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -193,9 +194,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -213,9 +214,9 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "core-foundation" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", @@ -264,9 +265,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "ctrlc" -version = "3.5.1" +version = "3.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73736a89c4aff73035ba2ed2e565061954da00d4970fc9ac25dcc85a2a20d790" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ "dispatch2", "nix", @@ -294,7 +295,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -305,7 +306,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -339,7 +340,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -349,7 +350,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -396,11 +397,11 @@ dependencies = [ [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.11.0", "block2", "libc", "objc2", @@ -414,9 +415,9 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "env_filter" -version = "0.1.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" dependencies = [ "log", "regex", @@ -424,9 +425,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" dependencies = [ "anstream", "anstyle", @@ -460,11 +461,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -478,24 +485,24 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -504,38 +511,37 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-macro", "futures-task", "pin-project-lite", - "pin-utils", "slab", ] @@ -599,9 +605,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", @@ -646,7 +652,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.11.0", "futures-channel", "futures-core", "futures-executor", @@ -674,7 +680,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -747,7 +753,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -758,9 +764,9 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" -version = "0.15.4" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heck" @@ -782,9 +788,9 @@ checksum = "1a68bc6edd83d2d86a8570adb0cfeb96a0cc9add78ad0885c14148dbc9ee4b97" [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -812,12 +818,12 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "indexmap" -version = "2.10.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.15.4", + "hashbrown 0.16.1", ] [[package]] @@ -828,9 +834,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "jiff" -version = "0.2.16" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" dependencies = [ "jiff-static", "log", @@ -841,20 +847,20 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.16" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -866,7 +872,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.11.0", "serde", "unicode-segmentation", ] @@ -897,9 +903,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.177" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libloading" @@ -913,11 +919,10 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ - "bitflags 2.8.0", "libc", ] @@ -942,25 +947,24 @@ dependencies = [ [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.25" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "memchr" -version = "2.7.5" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memoffset" @@ -998,17 +1002,17 @@ dependencies = [ "objc2-foundation", "once_cell", "png", - "thiserror 2.0.17", + "thiserror 2.0.18", "windows-sys 0.60.2", ] [[package]] name = "nix" -version = "0.30.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -1025,9 +1029,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -1038,7 +1042,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.11.0", "block2", "libc", "objc2", @@ -1059,7 +1063,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.11.0", "objc2", "objc2-foundation", ] @@ -1070,7 +1074,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.11.0", "objc2", "objc2-foundation", ] @@ -1081,7 +1085,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.11.0", "dispatch2", "objc2", ] @@ -1092,7 +1096,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.11.0", "dispatch2", "objc2", "objc2-core-foundation", @@ -1115,7 +1119,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.11.0", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -1127,7 +1131,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.11.0", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -1146,7 +1150,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.11.0", "block2", "libc", "objc2", @@ -1159,7 +1163,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.11.0", "objc2", "objc2-core-foundation", ] @@ -1170,16 +1174,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.11.0", "objc2", "objc2-foundation", ] [[package]] name = "once_cell" -version = "1.20.2" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" @@ -1231,28 +1235,22 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-link", ] [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" @@ -1275,15 +1273,15 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" dependencies = [ "portable-atomic", ] @@ -1334,29 +1332,29 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.93" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.38" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "redox_syscall" -version = "0.5.8" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.11.0", ] [[package]] @@ -1378,14 +1376,14 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom", "libredox", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -1395,9 +1393,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -1406,9 +1404,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "rustc_version" @@ -1464,7 +1462,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1484,25 +1482,25 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.13.2" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "something_bg" -version = "1.7.0" +version = "1.8.0" dependencies = [ "chrono", "core-foundation", @@ -1521,7 +1519,7 @@ dependencies = [ [[package]] name = "something_bg_core" -version = "1.5.2" +version = "1.6.0" dependencies = [ "chrono", "croner", @@ -1534,7 +1532,7 @@ dependencies = [ [[package]] name = "something_bg_linux" -version = "1.7.0" +version = "1.8.0" dependencies = [ "chrono", "ctrlc", @@ -1552,7 +1550,7 @@ dependencies = [ [[package]] name = "something_bg_windows" -version = "1.7.0" +version = "1.8.0" dependencies = [ "ctrlc", "dirs 5.0.1", @@ -1586,7 +1584,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1601,9 +1599,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.110" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -1640,11 +1638,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -1655,18 +1653,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1717,9 +1715,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.21.2" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3d5572781bee8e3f994d7467084e1b1fd7a93ce66bd480f8156ba89dee55a2b" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" dependencies = [ "crossbeam-channel", "dirs 6.0.0", @@ -1732,15 +1730,15 @@ dependencies = [ "objc2-foundation", "once_cell", "png", - "thiserror 2.0.17", + "thiserror 2.0.18", "windows-sys 0.60.2", ] [[package]] name = "unicode-ident" -version = "1.0.15" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11cd88e12b17c6494200a9c1b683a04fcac9573ed74cd1b62aeb2727c5592243" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" @@ -1774,9 +1772,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -1787,9 +1785,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1797,22 +1795,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] @@ -1860,7 +1858,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1871,7 +1869,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1940,22 +1938,6 @@ dependencies = [ "windows_x86_64_msvc 0.48.5", ] -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - [[package]] name = "windows-targets" version = "0.53.5" @@ -1966,7 +1948,7 @@ dependencies = [ "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", + "windows_i686_gnullvm", "windows_i686_msvc 0.53.1", "windows_x86_64_gnu 0.53.1", "windows_x86_64_gnullvm 0.53.1", @@ -1979,12 +1961,6 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" @@ -1997,12 +1973,6 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - [[package]] name = "windows_aarch64_msvc" version = "0.53.1" @@ -2015,24 +1985,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - [[package]] name = "windows_i686_gnullvm" version = "0.53.1" @@ -2045,12 +2003,6 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - [[package]] name = "windows_i686_msvc" version = "0.53.1" @@ -2063,12 +2015,6 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - [[package]] name = "windows_x86_64_gnu" version = "0.53.1" @@ -2081,12 +2027,6 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" @@ -2099,12 +2039,6 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - [[package]] name = "windows_x86_64_msvc" version = "0.53.1" diff --git a/Cargo.toml b/Cargo.toml index e5640ec..0b92b95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["core", "app-macos", "app-linux", "app-windows"] resolver = "2" [workspace.package] -version = "1.7.0" +version = "1.8.0" edition = "2024" [profile.release] diff --git a/README.md b/README.md index afb6889..ea94629 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,14 @@ A native menu bar utility for managing background processes, SSH tunnels, and sc ## Features -- Run SSH tunnels, port forwards, and development servers without keeping terminals open -- Schedule tasks using cron syntax -- Toggle services on/off from the menu bar +- Tiny native macOS app with a Rust core (less than 1MB) +- Run any script or CLI task without keeping a terminal open +- Fire-and-forget one-time commands (silent, with notification, or in a terminal) +- Auto-discover scripts from a directory +- Run scripts on a schedule without cron, or launchd +- Controlled from the menu bar - Cross-platform support (macOS, Linux, Windows) -- Lightweight (<1MB) with simple configuration +- Everything is configured with one simple config file ## Installation @@ -74,9 +77,14 @@ Configuration is stored in `~/.config/something_bg/config.toml` (created on firs ```toml path = "/bin:/usr/bin:/usr/local/bin:/opt/homebrew/bin" -# SSH tunnel -[tunnels.database] -name = "Database (PROD)" +# Optional: auto-discover .sh scripts from this directory +scripts_dir = "~/.config/something_bg/scripts" + +[tunnels] + +# SSH tunnel with port forwarding +[tunnels.database-prod] +name = "PROD" command = "ssh" args = ["-N", "-L", "5432:localhost:5432", "user@server.com"] kill_command = "pkill" @@ -94,8 +102,23 @@ kill_args = ["-f", "svc/api"] group_header = "KUBERNETES" separator_after = true -# Scheduled task -[schedules.backup] +# One-time commands (fire-and-forget) +[commands.fix-quarantine] +name = "Fix Whisperer Quarantine" +command = "xattr" +args = ["-dr", "com.apple.quarantine", "/Applications/whisperer.app"] +group_header = "PERSONAL" +group_icon = "sf:person.fill" + +[commands.deploy] +name = "Deploy" +command = "bash" +args = ["/Users/me/scripts/deploy.sh"] +output = "terminal" # Opens in Terminal.app +separator_after = true + +# Scheduled tasks +[schedules.daily-backup] name = "Daily Backup" command = "/usr/local/bin/backup.sh" args = [] @@ -114,12 +137,64 @@ group_icon = "sf:clock.fill" - `group_icon` _(optional)_ — SF Symbol (e.g., `sf:cylinder.fill`) - `separator_after` _(optional)_ — Add separator -**Schedules** (cron jobs): -- `name` — Display name -- `command`, `args` — Command to run -- `cron_schedule` — Cron expression (`minute hour day month weekday`) +**For Commands:** +- `name`: Display name in the menu +- `command` + `args`: Command to execute +- `output`: Output mode — `"silent"` (default), `"notify"`, or `"terminal"` (see below) + +**For Scheduled Tasks:** +- `name`: Display name in the menu +- `command` + `args`: Command to execute +- `cron_schedule`: Cron expression for scheduling (e.g., "0 6 * * *") + +**Optional fields (all types):** +- `group_header`: Section title (e.g., "DATABASE", "SCHEDULED TASKS") +- `group_icon`: SF Symbol name for the header (e.g., "sf:cylinder.fill", "sf:clock.fill") +- `separator_after`: Add a visual separator line after this item + +### One-Time Commands + +Run any command with a single click from the menu bar. Each command has a configurable `output` mode: + +| Mode | Behavior | Best for | +|------|----------|----------| +| `silent` (default) | Fire and forget, no output | Instant commands (`xattr`, `pkill`) | +| `notify` | Run in background, show notification on completion with last 5 lines of output | Scripts that take seconds to minutes | +| `terminal` | Open a terminal window with live output | Long/interactive scripts, debugging | + +```toml +[commands.fix-quarantine] +name = "Fix Quarantine" +command = "xattr" +args = ["-dr", "com.apple.quarantine", "/Applications/myapp.app"] +# output defaults to "silent" + +[commands.backup] +name = "Run Backup" +command = "bash" +args = ["/usr/local/bin/backup.sh"] +output = "notify" # Shows notification when done + +[commands.deploy] +name = "Deploy" +command = "bash" +args = ["/usr/local/bin/deploy.sh"] +output = "terminal" # Opens in Terminal.app +``` + +### Scripts Directory + +Auto-discover shell scripts from a directory. All `*.sh` files appear in the menu under a "Scripts" header, sorted alphabetically. Default output mode is `notify`. + +```toml +scripts_dir = "~/.config/something_bg/scripts" +``` + +Filenames are title-cased for display: `delete-logs.sh` → "Delete Logs". + +### Scheduled Tasks -**Common cron patterns**: +Common cron patterns: - `0 * * * *` — Every hour - `*/15 * * * *` — Every 15 minutes - `0 6 * * *` — Daily at 6am diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 90dfb4c..4185158 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,16 @@ # Release Notes - Something in the Background +## v1.8.0 + +**Release Date:** March 4, 2026 + +### 🚀 One-Time Commands & Scripts Directory + +- `[commands]` config section for fire-and-forget commands with three output modes: `silent`, `notify`, `terminal` +- `scripts_dir` config option to auto-discover `.sh` scripts (sorted alphabetically, default `notify`) +- Native macOS notifications with app icon, elapsed time, and "View History" button +- Command history logged to `command_history.log` + ## v1.7.0 **Release Date:** December 17, 2025 @@ -34,7 +45,7 @@ ### 🔠 Menu Polish -- “Next run” and “Last run” entries now start with capitalized relative time text for better readability in the scheduled task submenus. +- "Next run" and "Last run" entries now start with capitalized relative time text for better readability in the scheduled task submenus. ## v1.5.0 @@ -361,7 +372,7 @@ Future releases may include: ``` ❌ Random order every time: ├── Open tunnel PROD -├── Langfuse port forward +├── Langfuse port forward ├── Open tunnel DEV-01 └── Colima Docker ``` @@ -370,7 +381,7 @@ Future releases may include: ``` ✅ Consistent order matching config.toml: ├── Open tunnel PROD (from [tunnels.prod]) -├── Open tunnel DEV-01 (from [tunnels.dev-01]) +├── Open tunnel DEV-01 (from [tunnels.dev-01]) ├── Langfuse port forward (from [tunnels.k8s-langfuse]) └── Colima Docker (from [tunnels.colima]) ``` @@ -409,7 +420,7 @@ Your `~/.config/something_bg/config.toml` order is now preserved: [tunnels.first-tunnel] # ← Will appear first in menu name = "First Tunnel" -[tunnels.second-tunnel] # ← Will appear second in menu +[tunnels.second-tunnel] # ← Will appear second in menu name = "Second Tunnel" [tunnels.third-tunnel] # ← Will appear third in menu @@ -424,7 +435,7 @@ If you encounter any issues with this release, please: 2. Verify your `config.toml` file is properly formatted 3. Report issues with: - Your macOS version - - Contents of your `config.toml` + - Contents of your `config.toml` - Steps to reproduce the problem ## 💡 What's Next diff --git a/app-linux/src/app.rs b/app-linux/src/app.rs index 252ad96..c432d9e 100644 --- a/app-linux/src/app.rs +++ b/app-linux/src/app.rs @@ -1,7 +1,9 @@ use std::sync::{Arc, Mutex}; use log::{error, info, warn}; +use something_bg_core::command::CommandRunner; use something_bg_core::config::Config; +use something_bg_core::platform::AppPaths; use something_bg_core::scheduler::TaskScheduler; use something_bg_core::tunnel::TunnelManager; @@ -11,6 +13,7 @@ use crate::paths::LinuxPaths; /// Holds the tunnel manager and scheduler so menu handlers can drive them. pub struct AppState { pub tunnel_manager: TunnelManager, + pub command_runner: CommandRunner, pub scheduler: Arc, pub paths: Arc, } @@ -42,6 +45,63 @@ impl AppState { env_path: config.get_path(), }; + // Initialize the command runner + let mut command_runner = CommandRunner::new(config.get_path()); + let history_log = paths + .config_path() + .parent() + .unwrap() + .join("command_history.log"); + command_runner.set_history_path(history_log); + + // Set Linux notify callback using notify-send + command_runner.set_notify_callback(std::sync::Arc::new(|event| { + if event.is_running { + if let Err(e) = std::process::Command::new("notify-send") + .args([event.name, "\u{23f3} Running..."]) + .spawn() + { + log::warn!("Failed to send notification: {}", e); + } + return; + } + let title = if event.success { + format!("{} completed", event.name) + } else { + format!("{} failed", event.name) + }; + if let Err(e) = std::process::Command::new("notify-send") + .args([&title, event.output]) + .spawn() + { + log::warn!("Failed to send notification: {}", e); + } + })); + + // Set Linux terminal callback + command_runner.set_terminal_callback(std::sync::Arc::new(|command, args| { + let full_cmd = if args.is_empty() { + command.to_string() + } else { + format!("{} {}", command, args.join(" ")) + }; + if let Err(e) = std::process::Command::new("x-terminal-emulator") + .args(["-e", &full_cmd]) + .spawn() + { + log::warn!("Failed to open terminal (trying xterm): {}", e); + if let Err(e2) = std::process::Command::new("xterm") + .args(["-e", &full_cmd]) + .spawn() + { + log::error!("Failed to open xterm: {}", e2); + } + } + })); + + // Register commands from config + command_runner.register_all(&config.commands); + // Initialize the task scheduler let scheduler = Arc::new(TaskScheduler::new(path, paths.as_ref())); @@ -70,6 +130,7 @@ impl AppState { ( Self { tunnel_manager, + command_runner, scheduler, paths, }, diff --git a/app-linux/src/main.rs b/app-linux/src/main.rs index 78922ea..571b8fc 100644 --- a/app-linux/src/main.rs +++ b/app-linux/src/main.rs @@ -137,6 +137,11 @@ impl EventLoop { MenuAction::ToggleTunnel(key) => { self.toggle_tunnel(&key); } + MenuAction::RunCommand(key) => { + if let Err(e) = self.app_state.command_runner.run_by_key(&key) { + error!("command '{}' failed: {}", key, e); + } + } MenuAction::RunTask(key) => { if let Err(e) = self.app_state.scheduler.run_task_now(&key) { error!("task '{}' failed: {}", key, e); @@ -148,6 +153,9 @@ impl EventLoop { open_about(); } MenuAction::OpenConfig => open_config(&self.app_state.paths), + MenuAction::ViewHistory => { + open_history(&self.app_state.command_runner); + } MenuAction::Quit => { self.running.store(false, Ordering::SeqCst); } @@ -199,6 +207,19 @@ fn open_config(paths: &std::sync::Arc) { } } +fn open_history(command_runner: &something_bg_core::command::CommandRunner) { + if let Some(path) = command_runner.history_path() { + if path.exists() { + info!("opening command history at {:?}", path); + if let Err(e) = Command::new("xdg-open").arg(path).spawn() { + warn!("failed to open history: {e}"); + } + } else { + info!("no command history yet"); + } + } +} + fn open_about() { let url = "https://github.com/vim-zz/something_bg"; info!("opening project page: {url}"); diff --git a/app-linux/src/menu.rs b/app-linux/src/menu.rs index 73215bb..8e39203 100644 --- a/app-linux/src/menu.rs +++ b/app-linux/src/menu.rs @@ -1,16 +1,18 @@ use std::collections::HashMap; use log::debug; -use something_bg_core::config::{Config, ScheduledTaskConfig, TunnelConfig}; +use something_bg_core::config::Config; use something_bg_core::scheduler::{TaskScheduler, cron_to_human_readable, format_last_run}; use tray_icon::menu::{CheckMenuItem, Menu, MenuId, MenuItem, PredefinedMenuItem}; /// Holds references to menu items so we can update their checked state / labels. pub struct MenuHandles { pub tunnels: Vec, + pub commands: Vec, pub tasks: Vec, pub about_id: MenuId, pub open_config_id: MenuId, + pub view_history_id: Option, pub quit_id: MenuId, } @@ -20,6 +22,11 @@ pub struct TunnelHandle { pub item: CheckMenuItem, } +pub struct CommandHandle { + pub id: MenuId, + pub key: String, +} + pub struct TaskHandle { pub key: String, pub run_id: MenuId, @@ -31,7 +38,7 @@ pub fn build_menu(config: &Config, scheduler: &TaskScheduler) -> (Menu, MenuHand let mut tunnels = Vec::new(); for (key, tunnel) in &config.tunnels { - add_group_header(&menu, tunnel); + maybe_add_group_header(&menu, tunnel.group_header.as_deref()); let item = CheckMenuItem::new(&tunnel.name, true, false, None); let id = item.id(); @@ -51,7 +58,43 @@ pub fn build_menu(config: &Config, scheduler: &TaskScheduler) -> (Menu, MenuHand } } - if !config.schedules.is_empty() && !config.tunnels.is_empty() { + let mut commands = Vec::new(); + let mut view_history_id = None; + if !config.commands.is_empty() { + if !config.tunnels.is_empty() { + if let Err(e) = menu.append(&PredefinedMenuItem::separator()) { + debug!("failed to append separator: {e}"); + } + } + + for (key, cmd) in &config.commands { + maybe_add_group_header(&menu, cmd.group_header.as_deref()); + + let item = MenuItem::new(&cmd.name, true, None); + let id = item.id().clone(); + if let Err(e) = menu.append(&item) { + debug!("failed to append command item: {e}"); + } + commands.push(CommandHandle { + id, + key: key.clone(), + }); + + if cmd.separator_after.unwrap_or(false) { + if let Err(e) = menu.append(&PredefinedMenuItem::separator()) { + debug!("failed to append separator: {e}"); + } + } + } + + let view_history = MenuItem::new("View command history", true, None); + view_history_id = Some(view_history.id().clone()); + if let Err(e) = menu.append(&view_history) { + debug!("failed to append view-history item: {e}"); + } + } + + if !config.schedules.is_empty() && (!config.tunnels.is_empty() || !config.commands.is_empty()) { if let Err(e) = menu.append(&PredefinedMenuItem::separator()) { debug!("failed to append separator: {e}"); } @@ -59,7 +102,7 @@ pub fn build_menu(config: &Config, scheduler: &TaskScheduler) -> (Menu, MenuHand let mut tasks = Vec::new(); for (key, task) in &config.schedules { - add_group_header_for_task(&menu, task); + maybe_add_group_header(&menu, task.group_header.as_deref()); let schedule_line = format!("Schedule: {}", cron_to_human_readable(&task.cron_schedule)); let schedule_item = MenuItem::new(&schedule_line, false, None); @@ -97,7 +140,7 @@ pub fn build_menu(config: &Config, scheduler: &TaskScheduler) -> (Menu, MenuHand } } - if !config.tunnels.is_empty() || !config.schedules.is_empty() { + if !config.tunnels.is_empty() || !config.commands.is_empty() || !config.schedules.is_empty() { if let Err(e) = menu.append(&PredefinedMenuItem::separator()) { debug!("failed to append separator: {e}"); } @@ -125,25 +168,18 @@ pub fn build_menu(config: &Config, scheduler: &TaskScheduler) -> (Menu, MenuHand menu, MenuHandles { tunnels, + commands, tasks, about_id, open_config_id, + view_history_id, quit_id, }, ) } -fn add_group_header(menu: &Menu, tunnel: &TunnelConfig) { - if let Some(header) = &tunnel.group_header { - let item = MenuItem::new(header, false, None); - if let Err(e) = menu.append(&item) { - debug!("failed to append group header: {e}"); - } - } -} - -fn add_group_header_for_task(menu: &Menu, task: &ScheduledTaskConfig) { - if let Some(header) = &task.group_header { +fn maybe_add_group_header(menu: &Menu, header: Option<&str>) { + if let Some(header) = header { let item = MenuItem::new(header, false, None); if let Err(e) = menu.append(&item) { debug!("failed to append group header: {e}"); @@ -172,11 +208,17 @@ pub fn build_id_lookup(handles: &MenuHandles) -> HashMap { for t in &handles.tunnels { map.insert(t.id.clone(), MenuAction::ToggleTunnel(t.key.clone())); } + for c in &handles.commands { + map.insert(c.id.clone(), MenuAction::RunCommand(c.key.clone())); + } for t in &handles.tasks { map.insert(t.run_id.clone(), MenuAction::RunTask(t.key.clone())); } map.insert(handles.about_id.clone(), MenuAction::About); map.insert(handles.open_config_id.clone(), MenuAction::OpenConfig); + if let Some(id) = &handles.view_history_id { + map.insert(id.clone(), MenuAction::ViewHistory); + } map.insert(handles.quit_id.clone(), MenuAction::Quit); map } @@ -184,8 +226,10 @@ pub fn build_id_lookup(handles: &MenuHandles) -> HashMap { #[derive(Clone, Debug)] pub enum MenuAction { ToggleTunnel(String), + RunCommand(String), RunTask(String), About, OpenConfig, + ViewHistory, Quit, } diff --git a/app-macos/src/app.rs b/app-macos/src/app.rs index 176d307..add4285 100644 --- a/app-macos/src/app.rs +++ b/app-macos/src/app.rs @@ -5,10 +5,14 @@ use log::{error, info, warn}; use objc2::rc::Retained; +use objc2::runtime::{AnyClass, AnyObject}; +use objc2::{ClassType, MainThreadOnly, define_class}; use objc2_app_kit::NSStatusItem; +use objc2_foundation::{MainThreadMarker, NSObject, NSObjectProtocol, NSString}; use std::collections::HashSet; use std::sync::{Arc, Mutex}; +use something_bg_core::command::{CommandRunner, format_duration as format_elapsed}; use something_bg_core::config::Config; use something_bg_core::platform::AppPaths; use something_bg_core::scheduler::TaskScheduler; @@ -25,6 +29,7 @@ unsafe impl Sync for StatusItemWrapper {} /// must be shared across modules (e.g., commands, active tunnels). pub struct App { pub tunnel_manager: TunnelManager, + pub command_runner: CommandRunner, pub task_scheduler: TaskScheduler, pub paths: Arc, pub status_item: Option>>, @@ -58,6 +63,68 @@ impl App { env_path: config.get_path(), }; + // Initialize the command runner + let mut command_runner = CommandRunner::new(config.get_path()); + let history_log = paths + .config_path() + .parent() + .unwrap() + .join("command_history.log"); + command_runner.set_history_path(history_log); + + // Set macOS notify callback using native NSUserNotificationCenter + // (shows the app icon instead of Script Editor) + command_runner.set_notify_callback(std::sync::Arc::new(|event| { + if event.is_running { + send_notification(event.name, "\u{23f3} Running..."); + return; + } + let time_str = match event.elapsed { + Some(d) if d.as_secs() >= 2 => format!(" ({})", format_elapsed(d)), + _ => String::new(), + }; + let status = if event.success { + "\u{2705} Completed" + } else { + "\u{274c} Failed" + }; + let body = if event.output.is_empty() { + format!("{}{}", status, time_str) + } else { + format!("{}{}\n{}", status, time_str, event.output) + }; + send_notification(event.name, &body); + })); + + // Set macOS terminal callback using osascript + command_runner.set_terminal_callback(std::sync::Arc::new(|command, args| { + let full_cmd = if args.is_empty() { + command.to_string() + } else { + format!( + "{} {}", + command, + args.iter() + .map(|a| format!("\"{}\"", a.replace('"', "\\\""))) + .collect::>() + .join(" ") + ) + }; + let script = format!( + "tell application \"Terminal\" to do script \"{}\"", + full_cmd.replace('\\', "\\\\").replace('"', "\\\"") + ); + if let Err(e) = std::process::Command::new("osascript") + .args(["-e", &script]) + .spawn() + { + log::warn!("Failed to open Terminal: {}", e); + } + })); + + // Register commands from config + command_runner.register_all(&config.commands); + // Initialize the task scheduler let task_scheduler = TaskScheduler::new(path, paths.as_ref()); @@ -85,6 +152,7 @@ impl App { Self { tunnel_manager, + command_runner, task_scheduler, paths, status_item: None, @@ -122,3 +190,84 @@ impl App { self.paths.config_path() } } + +// Notification delegate: handles "Show" button clicks on notifications +define_class!( + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "NotifDelegate"] + pub struct NotifDelegate; + + unsafe impl NSObjectProtocol for NotifDelegate {} + + impl NotifDelegate { + #[unsafe(method(userNotificationCenter:didActivateNotification:))] + fn did_activate(&self, _center: &AnyObject, _notification: &AnyObject) { + if let Some(app) = crate::GLOBAL_APP.get() + && let Some(path) = app.command_runner.history_path() + && path.exists() + { + let _ = std::process::Command::new("open").arg(path).spawn(); + } + } + + // Always show notifications even when the app is active (menu bar app) + #[unsafe(method(userNotificationCenter:shouldPresentNotification:))] + fn should_present(&self, _center: &AnyObject, _notification: &AnyObject) -> bool { + true + } + } +); + +impl NotifDelegate { + pub fn new(_mtm: MainThreadMarker) -> Retained { + let cls = Self::class(); + unsafe { objc2::msg_send![cls, new] } + } +} + +/// Set up native notification delivery with the app's icon and click-to-view-history. +/// Must be called on the main thread after GLOBAL_APP is set. +pub fn setup_notification_center(mtm: MainThreadMarker) { + unsafe { + let Some(center_class) = AnyClass::get(c"NSUserNotificationCenter") else { + warn!("NSUserNotificationCenter not available"); + return; + }; + let center: Retained = + objc2::msg_send![center_class, defaultUserNotificationCenter]; + let delegate = NotifDelegate::new(mtm); + let _: () = objc2::msg_send![¢er, setDelegate: &*delegate]; + // Delegate must stay alive for the app lifetime; intentional leak for singleton + std::mem::forget(delegate); + } + info!("Native notification center configured"); +} + +/// Send a native macOS notification using NSUserNotificationCenter. +/// Shows the app's icon and supports the "Show" action button. +pub fn send_notification(title: &str, body: &str) { + unsafe { + let Some(center_class) = AnyClass::get(c"NSUserNotificationCenter") else { + warn!("NSUserNotificationCenter class not available"); + return; + }; + let center: Retained = + objc2::msg_send![center_class, defaultUserNotificationCenter]; + + let Some(notif_class) = AnyClass::get(c"NSUserNotification") else { + warn!("NSUserNotification class not available"); + return; + }; + let notif: Retained = objc2::msg_send![notif_class, new]; + + let title_ns = NSString::from_str(title); + let body_ns = NSString::from_str(body); + let action_ns = NSString::from_str("View History"); + + let _: () = objc2::msg_send![¬if, setTitle: &*title_ns]; + let _: () = objc2::msg_send![¬if, setInformativeText: &*body_ns]; + let _: () = objc2::msg_send![¬if, setActionButtonTitle: &*action_ns]; + let _: () = objc2::msg_send![¢er, deliverNotification: &*notif]; + } +} diff --git a/app-macos/src/main.rs b/app-macos/src/main.rs index 46b194f..6a62f7c 100644 --- a/app-macos/src/main.rs +++ b/app-macos/src/main.rs @@ -55,6 +55,9 @@ fn main() { the_app.set_status_item(status_item); GLOBAL_APP.set(the_app).ok().unwrap(); + // 5b. Set up native notification center (shows app icon, handles "Show" clicks) + app::setup_notification_center(mtm); + // 6. Setup wake observer to detect when Mac wakes from sleep wake_detector::set_wake_callback(|| { if let Some(app) = GLOBAL_APP.get() { diff --git a/app-macos/src/menu.rs b/app-macos/src/menu.rs index 357c8de..5427f7a 100644 --- a/app-macos/src/menu.rs +++ b/app-macos/src/menu.rs @@ -14,7 +14,7 @@ use objc2_foundation::{MainThreadMarker, NSObject, NSObjectProtocol, NSString, n use crate::GLOBAL_APP; use crate::paths::MacPaths; -use something_bg_core::config::{Config, ScheduledTaskConfig, TunnelConfig}; +use something_bg_core::config::{CommandConfig, Config, ScheduledTaskConfig, TunnelConfig}; use something_bg_core::platform::AppPaths; fn load_config() -> Config { @@ -91,6 +91,16 @@ define_class!( fn disconnect_all(&self, _item: &NSMenuItem) { disconnect_all_handler(); } + + #[unsafe(method(runCommand:))] + fn run_command(&self, item: &NSMenuItem) { + run_command_handler(item); + } + + #[unsafe(method(viewCommandHistory:))] + fn view_command_history(&self, _item: &NSMenuItem) { + view_command_history_handler(); + } } ); @@ -138,6 +148,23 @@ fn run_scheduled_task_handler(item: &NSMenuItem) { } } +/// Handler function for running a one-time command +fn run_command_handler(item: &NSMenuItem) { + use log::info; + + if let Some(represented_obj) = item.representedObject() { + let command_key = extract_nsstring_from_object(&represented_obj); + + info!("Running command: {}", command_key); + + if let Some(app) = crate::GLOBAL_APP.get() + && let Err(e) = app.command_runner.run_by_key(&command_key) + { + error!("Failed to run command '{}': {}", command_key, e); + } + } +} + /// Safely extracts an NSString from a represented object /// SAFETY: Caller must ensure the object is actually an NSString fn extract_nsstring_from_object(obj: &AnyObject) -> String { @@ -174,6 +201,25 @@ fn set_menu_item_target(item: &NSMenuItem, target: &AnyObject) { unsafe { item.setTarget(Some(target)) }; } +/// Open the command history log file in the default text editor. +fn view_command_history_handler() { + use log::{error, info}; + use std::process::Command; + + if let Some(app) = GLOBAL_APP.get() { + if let Some(path) = app.command_runner.history_path() { + if path.exists() { + match Command::new("open").arg(path).spawn() { + Ok(_) => info!("Opened command history log"), + Err(e) => error!("Failed to open command history: {}", e), + } + } else { + info!("No command history yet"); + } + } + } +} + /// Open the config folder in Finder using the current app paths. fn open_config_folder_handler() { use log::{error, info}; @@ -366,6 +412,40 @@ pub fn create_menu(handler: &MenuHandler, mtm: MainThreadMarker) -> Retained Retained { + let title_ns = NSString::from_str(&cmd_config.name); + let item = + create_menu_item_with_action(&title_ns, Some(sel!(runCommand:)), ns_string!(""), mtm); + + let key_ns = NSString::from_str(command_key); + set_menu_item_represented_object(&item, &key_ns); + set_menu_item_target(&item, handler as &AnyObject); + + item +} + /// Helper to create a menu item for a scheduled task with submenu fn create_scheduled_task_item( handler: &MenuHandler, diff --git a/app-windows/src/app.rs b/app-windows/src/app.rs index bc80318..cea5406 100644 --- a/app-windows/src/app.rs +++ b/app-windows/src/app.rs @@ -1,6 +1,7 @@ use std::sync::{Arc, Mutex}; use log::{error, info, warn}; +use something_bg_core::command::CommandRunner; use something_bg_core::config::Config; use something_bg_core::scheduler::TaskScheduler; use something_bg_core::tunnel::TunnelManager; @@ -10,6 +11,7 @@ use crate::paths::WindowsPaths; /// Shared application state for the Windows shell. pub struct AppState { pub tunnel_manager: TunnelManager, + pub command_runner: CommandRunner, pub scheduler: Arc, pub paths: Arc, } @@ -39,6 +41,63 @@ impl AppState { env_path: config.get_path(), }; + // Initialize the command runner + let mut command_runner = CommandRunner::new(config.get_path()); + let history_log = paths + .config_path() + .parent() + .unwrap() + .join("command_history.log"); + command_runner.set_history_path(history_log); + + // Set Windows notify callback using PowerShell toast notification + command_runner.set_notify_callback(std::sync::Arc::new(|event| { + if event.is_running { + return; // Windows toast notifications auto-dismiss; skip running indicator + } + let title = if event.success { + format!("{} completed", event.name) + } else { + format!("{} failed", event.name) + }; + let body = event.output.replace('\'', "''"); + let ps_script = format!( + "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null; \ + $xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02); \ + $text = $xml.GetElementsByTagName('text'); \ + $text[0].AppendChild($xml.CreateTextNode('{}')) > $null; \ + $text[1].AppendChild($xml.CreateTextNode('{}')) > $null; \ + $toast = [Windows.UI.Notifications.ToastNotification]::new($xml); \ + [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('something_bg').Show($toast)", + title.replace('\'', "''"), + body + ); + if let Err(e) = std::process::Command::new("powershell") + .args(["-Command", &ps_script]) + .spawn() + { + log::warn!("Failed to send notification: {}", e); + } + })); + + // Set Windows terminal callback + command_runner.set_terminal_callback(std::sync::Arc::new(|command, args| { + let full_cmd = if args.is_empty() { + command.to_string() + } else { + format!("{} {}", command, args.join(" ")) + }; + if let Err(e) = std::process::Command::new("cmd") + .args(["/C", "start", "cmd", "/K", &full_cmd]) + .spawn() + { + log::warn!("Failed to open terminal: {}", e); + } + })); + + // Register commands from config + command_runner.register_all(&config.commands); + let scheduler = Arc::new(TaskScheduler::new(path, paths.as_ref())); for (key, task_config) in &config.schedules { if let Err(e) = scheduler.add_task(key.clone(), task_config) { @@ -58,6 +117,7 @@ impl AppState { ( Self { tunnel_manager, + command_runner, scheduler, paths, }, diff --git a/app-windows/src/main.rs b/app-windows/src/main.rs index a38a279..8a34b40 100644 --- a/app-windows/src/main.rs +++ b/app-windows/src/main.rs @@ -125,6 +125,11 @@ impl EventLoop { MenuAction::ToggleTunnel(key) => { self.toggle_tunnel(&key); } + MenuAction::RunCommand(key) => { + if let Err(e) = self.app_state.command_runner.run_by_key(&key) { + error!("command '{}' failed: {}", key, e); + } + } MenuAction::RunTask(key) => { if let Err(e) = self.app_state.scheduler.run_task_now(&key) { error!("task '{}' failed: {}", key, e); @@ -134,6 +139,9 @@ impl EventLoop { } MenuAction::About => open_about(), MenuAction::OpenConfig => open_config(&self.app_state.paths), + MenuAction::ViewHistory => { + open_history(&self.app_state.command_runner); + } MenuAction::Quit => { self.running.store(false, Ordering::SeqCst); } @@ -185,6 +193,22 @@ fn open_config(paths: &Arc) { } } +fn open_history(command_runner: &something_bg_core::command::CommandRunner) { + if let Some(path) = command_runner.history_path() { + if path.exists() { + info!("opening command history at {:?}", path); + if let Err(e) = Command::new("cmd") + .args(["/C", "start", "", &path.to_string_lossy()]) + .spawn() + { + warn!("failed to open history: {e}"); + } + } else { + info!("no command history yet"); + } + } +} + fn open_about() { let url = "https://github.com/vim-zz/something_bg"; info!("opening project page: {url}"); diff --git a/app-windows/src/menu.rs b/app-windows/src/menu.rs index 417d0eb..c402221 100644 --- a/app-windows/src/menu.rs +++ b/app-windows/src/menu.rs @@ -1,15 +1,17 @@ use std::collections::HashMap; use log::debug; -use something_bg_core::config::{Config, ScheduledTaskConfig, TunnelConfig}; +use something_bg_core::config::Config; use something_bg_core::scheduler::{TaskScheduler, cron_to_human_readable, format_last_run}; use tray_icon::menu::{CheckMenuItem, Menu, MenuId, MenuItem, PredefinedMenuItem}; pub struct MenuHandles { pub tunnels: Vec, + pub commands: Vec, pub tasks: Vec, pub about_id: MenuId, pub open_config_id: MenuId, + pub view_history_id: Option, pub quit_id: MenuId, } @@ -19,6 +21,11 @@ pub struct TunnelHandle { pub item: CheckMenuItem, } +pub struct CommandHandle { + pub id: MenuId, + pub key: String, +} + pub struct TaskHandle { pub key: String, pub run_id: MenuId, @@ -30,7 +37,7 @@ pub fn build_menu(config: &Config, scheduler: &TaskScheduler) -> (Menu, MenuHand let mut tunnels = Vec::new(); for (key, tunnel) in &config.tunnels { - add_group_header(&menu, tunnel); + maybe_add_group_header(&menu, tunnel.group_header.as_deref()); let item = CheckMenuItem::new(&tunnel.name, true, false, None); let id = item.id().clone(); @@ -50,7 +57,43 @@ pub fn build_menu(config: &Config, scheduler: &TaskScheduler) -> (Menu, MenuHand } } - if !config.schedules.is_empty() && !config.tunnels.is_empty() { + let mut commands = Vec::new(); + let mut view_history_id = None; + if !config.commands.is_empty() { + if !config.tunnels.is_empty() { + if let Err(e) = menu.append(&PredefinedMenuItem::separator()) { + debug!("failed to append separator: {e}"); + } + } + + for (key, cmd) in &config.commands { + maybe_add_group_header(&menu, cmd.group_header.as_deref()); + + let item = MenuItem::new(&cmd.name, true, None); + let id = item.id().clone(); + if let Err(e) = menu.append(&item) { + debug!("failed to append command item: {e}"); + } + commands.push(CommandHandle { + id, + key: key.clone(), + }); + + if cmd.separator_after.unwrap_or(false) { + if let Err(e) = menu.append(&PredefinedMenuItem::separator()) { + debug!("failed to append separator: {e}"); + } + } + } + + let view_history = MenuItem::new("View command history", true, None); + view_history_id = Some(view_history.id().clone()); + if let Err(e) = menu.append(&view_history) { + debug!("failed to append view-history item: {e}"); + } + } + + if !config.schedules.is_empty() && (!config.tunnels.is_empty() || !config.commands.is_empty()) { if let Err(e) = menu.append(&PredefinedMenuItem::separator()) { debug!("failed to append separator: {e}"); } @@ -58,7 +101,7 @@ pub fn build_menu(config: &Config, scheduler: &TaskScheduler) -> (Menu, MenuHand let mut tasks = Vec::new(); for (key, task) in &config.schedules { - add_group_header_for_task(&menu, task); + maybe_add_group_header(&menu, task.group_header.as_deref()); let schedule_line = format!("Schedule: {}", cron_to_human_readable(&task.cron_schedule)); let schedule_item = MenuItem::new(&schedule_line, false, None); @@ -96,7 +139,7 @@ pub fn build_menu(config: &Config, scheduler: &TaskScheduler) -> (Menu, MenuHand } } - if !config.tunnels.is_empty() || !config.schedules.is_empty() { + if !config.tunnels.is_empty() || !config.commands.is_empty() || !config.schedules.is_empty() { if let Err(e) = menu.append(&PredefinedMenuItem::separator()) { debug!("failed to append separator: {e}"); } @@ -124,25 +167,18 @@ pub fn build_menu(config: &Config, scheduler: &TaskScheduler) -> (Menu, MenuHand menu, MenuHandles { tunnels, + commands, tasks, about_id, open_config_id, + view_history_id, quit_id, }, ) } -fn add_group_header(menu: &Menu, tunnel: &TunnelConfig) { - if let Some(header) = &tunnel.group_header { - let item = MenuItem::new(header, false, None); - if let Err(e) = menu.append(&item) { - debug!("failed to append group header: {e}"); - } - } -} - -fn add_group_header_for_task(menu: &Menu, task: &ScheduledTaskConfig) { - if let Some(header) = &task.group_header { +fn maybe_add_group_header(menu: &Menu, header: Option<&str>) { + if let Some(header) = header { let item = MenuItem::new(header, false, None); if let Err(e) = menu.append(&item) { debug!("failed to append group header: {e}"); @@ -169,11 +205,17 @@ pub fn build_id_lookup(handles: &MenuHandles) -> HashMap { for t in &handles.tunnels { map.insert(t.id.clone(), MenuAction::ToggleTunnel(t.key.clone())); } + for c in &handles.commands { + map.insert(c.id.clone(), MenuAction::RunCommand(c.key.clone())); + } for t in &handles.tasks { map.insert(t.run_id.clone(), MenuAction::RunTask(t.key.clone())); } map.insert(handles.about_id.clone(), MenuAction::About); map.insert(handles.open_config_id.clone(), MenuAction::OpenConfig); + if let Some(id) = &handles.view_history_id { + map.insert(id.clone(), MenuAction::ViewHistory); + } map.insert(handles.quit_id.clone(), MenuAction::Quit); map } @@ -181,8 +223,10 @@ pub fn build_id_lookup(handles: &MenuHandles) -> HashMap { #[derive(Clone, Debug)] pub enum MenuAction { ToggleTunnel(String), + RunCommand(String), RunTask(String), About, OpenConfig, + ViewHistory, Quit, } diff --git a/core/Cargo.toml b/core/Cargo.toml index ba117d0..699102a 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "something_bg_core" -version = "1.5.2" +version = "1.6.0" edition = "2024" [dependencies] diff --git a/core/src/command.rs b/core/src/command.rs new file mode 100644 index 0000000..4cbc23b --- /dev/null +++ b/core/src/command.rs @@ -0,0 +1,329 @@ +//! One-time command runner with configurable output modes. +//! +//! Each command can run in one of three modes: +//! - **Silent**: fire-and-forget, no output captured +//! - **Notify**: run in background, capture output, send notification on completion +//! - **Terminal**: open a terminal emulator and execute the command there + +use log::{debug, error, info, warn}; +use std::collections::HashMap; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::thread; + +use crate::config::CommandConfig; + +/// How to handle command output. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputMode { + Silent, + Notify, + Terminal, +} + +impl OutputMode { + pub fn from_str_opt(s: Option<&str>) -> Self { + match s.map(|s| s.to_lowercase()).as_deref() { + Some("notify") => Self::Notify, + Some("terminal") => Self::Terminal, + _ => Self::Silent, + } + } +} + +struct CommandEntry { + name: String, + command: String, + args: Vec, + output_mode: OutputMode, +} + +/// Event passed to the notify callback with structured data. +pub struct NotifyEvent<'a> { + pub name: &'a str, + pub success: bool, + pub output: &'a str, + pub elapsed: Option, + pub is_running: bool, +} + +/// Callback invoked for "notify" mode commands (progress and completion). +pub type NotifyCallback = Arc; + +/// Callback invoked for "terminal" mode. +/// Parameters: (command, args) +pub type TerminalCallback = Arc; + +/// Runs one-time commands with configurable output handling. +pub struct CommandRunner { + env_path: String, + commands: HashMap, + notify_cb: Option, + terminal_cb: Option, + history_path: Option, +} + +impl CommandRunner { + pub fn new(env_path: String) -> Self { + Self { + env_path, + commands: HashMap::new(), + notify_cb: None, + terminal_cb: None, + history_path: None, + } + } + + pub fn set_history_path(&mut self, path: PathBuf) { + self.history_path = Some(path); + } + + /// Return the history log path, if configured. + pub fn history_path(&self) -> Option<&std::path::Path> { + self.history_path.as_deref() + } + + pub fn set_notify_callback(&mut self, cb: NotifyCallback) { + self.notify_cb = Some(cb); + } + + pub fn set_terminal_callback(&mut self, cb: TerminalCallback) { + self.terminal_cb = Some(cb); + } + + /// Register a command from a config entry. + pub fn add_from_config(&mut self, key: String, config: &CommandConfig) { + self.commands.insert( + key, + CommandEntry { + name: config.name.clone(), + command: config.command.clone(), + args: config.args.clone(), + output_mode: OutputMode::from_str_opt(config.output.as_deref()), + }, + ); + } + + /// Register all commands from a config slice. + pub fn register_all(&mut self, commands: &[(String, CommandConfig)]) { + for (key, cmd_config) in commands { + self.add_from_config(key.clone(), cmd_config); + } + } + + /// Run a registered command by its key. + pub fn run_by_key(&self, key: &str) -> Result<(), String> { + let entry = self + .commands + .get(key) + .ok_or_else(|| format!("Unknown command key: {}", key))?; + + info!( + "Running command '{}' ({}) in {:?} mode", + entry.name, key, entry.output_mode + ); + + match entry.output_mode { + OutputMode::Silent => self.run_silent(entry), + OutputMode::Notify => self.run_notify(entry), + OutputMode::Terminal => self.run_terminal(entry), + } + } + + fn run_silent(&self, entry: &CommandEntry) -> Result<(), String> { + let result = Command::new(&entry.command) + .args(&entry.args) + .env("PATH", &self.env_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + + match &result { + Ok(_) => { + append_history( + &self.history_path, + &entry.name, + true, + "(spawned, silent mode)", + ); + debug!("Spawned silent command: {}", entry.name); + } + Err(e) => { + let msg = format!("Failed to spawn: {}", e); + append_history(&self.history_path, &entry.name, false, &msg); + } + } + + result + .map(|_| ()) + .map_err(|e| format!("Failed to spawn '{}': {}", entry.command, e)) + } + + fn run_notify(&self, entry: &CommandEntry) -> Result<(), String> { + let cb = self.notify_cb.clone(); + let name = entry.name.clone(); + let command = entry.command.clone(); + let args = entry.args.clone(); + let env_path = self.env_path.clone(); + let history_path = self.history_path.clone(); + + thread::spawn(move || { + // Send "running" notification only if the command takes > 2 seconds + let cb_for_timer = cb.clone(); + let name_for_timer = name.clone(); + let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let done_clone = done.clone(); + thread::spawn(move || { + thread::sleep(std::time::Duration::from_secs(2)); + if !done_clone.load(std::sync::atomic::Ordering::Relaxed) + && let Some(cb) = cb_for_timer + { + cb(&NotifyEvent { + name: &name_for_timer, + success: true, + output: "", + elapsed: None, + is_running: true, + }); + } + }); + + let start_time = std::time::Instant::now(); + let result = Command::new(&command) + .args(&args) + .env("PATH", &env_path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output(); + + let elapsed = start_time.elapsed(); + done.store(true, std::sync::atomic::Ordering::Relaxed); + + match result { + Ok(output) => { + let success = output.status.success(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = if stderr.is_empty() { + stdout.to_string() + } else if stdout.is_empty() { + stderr.to_string() + } else { + format!("{}\n{}", stdout, stderr) + }; + + // Log full output to history + let history_entry = format!( + "Exit code: {} (took {})\n{}", + output.status.code().unwrap_or(-1), + format_duration(elapsed), + combined + ); + append_history(&history_path, &name, success, &history_entry); + + // Take last 5 lines for notification + let all_lines: Vec<&str> = combined.lines().collect(); + let start = all_lines.len().saturating_sub(5); + let last_lines = all_lines[start..].join("\n"); + + info!( + "Command '{}' finished (success={}, took {})", + name, + success, + format_duration(elapsed) + ); + + if let Some(cb) = cb { + cb(&NotifyEvent { + name: &name, + success, + output: &last_lines, + elapsed: Some(elapsed), + is_running: false, + }); + } + } + Err(e) => { + let msg = format!("Failed to execute: {}", e); + append_history(&history_path, &name, false, &msg); + error!("Failed to run command '{}': {}", name, e); + if let Some(cb) = cb { + cb(&NotifyEvent { + name: &name, + success: false, + output: &msg, + elapsed: None, + is_running: false, + }); + } + } + } + }); + + Ok(()) + } + + fn run_terminal(&self, entry: &CommandEntry) -> Result<(), String> { + if let Some(cb) = &self.terminal_cb { + cb(&entry.command, &entry.args); + append_history( + &self.history_path, + &entry.name, + true, + "(opened in terminal)", + ); + Ok(()) + } else { + Err("No terminal callback configured".to_string()) + } + } +} + +/// Append a timestamped entry to the command history log. +fn append_history(path: &Option, name: &str, success: bool, output: &str) { + let Some(path) = path else { return }; + + let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S"); + let status = if success { "OK" } else { "FAILED" }; + let entry = format!("=== [{timestamp}] {name} [{status}] ===\n{output}\n\n"); + + match OpenOptions::new().create(true).append(true).open(path) { + Ok(mut file) => { + if let Err(e) = file.write_all(entry.as_bytes()) { + warn!("Failed to write command history: {}", e); + } + } + Err(e) => { + warn!("Failed to open command history file {:?}: {}", path, e); + } + } +} + +/// Format a duration as a human-readable string (e.g. "14s", "2m 30s", "1h 5m"). +pub fn format_duration(d: std::time::Duration) -> String { + let secs = d.as_secs(); + if secs < 60 { + format!("{}s", secs) + } else if secs < 3600 { + let m = secs / 60; + let s = secs % 60; + if s == 0 { + format!("{}m", m) + } else { + format!("{}m {}s", m, s) + } + } else { + let h = secs / 3600; + let m = (secs % 3600) / 60; + if m == 0 { + format!("{}h", h) + } else { + format!("{}h {}m", h, m) + } + } +} diff --git a/core/src/config.rs b/core/src/config.rs index 7f2309c..22240c8 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -1,10 +1,11 @@ //! Configuration loading and management. //! Uses injected `AppPaths` so platform shells control where files live. -use log::{debug, info}; +use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; +use std::path::Path; use crate::platform::AppPaths; use crate::tunnel::TunnelCommand; @@ -13,8 +14,14 @@ use crate::tunnel::TunnelCommand; #[derive(Serialize)] struct ConfigForSerialization { tunnels: HashMap, + #[serde(skip_serializing_if = "HashMap::is_empty")] + commands: HashMap, schedules: HashMap, #[serde(skip_serializing_if = "Option::is_none")] + scripts_dir: Option, + #[serde(skip_serializing_if = "Option::is_none")] + scripts_output: Option, + #[serde(skip_serializing_if = "Option::is_none")] path: Option, } @@ -47,11 +54,33 @@ pub struct ScheduledTaskConfig { pub group_icon: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommandConfig { + pub name: String, + pub command: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub output: Option, + #[serde(default)] + pub separator_after: Option, + #[serde(default)] + pub group_header: Option, + #[serde(default)] + pub group_icon: Option, +} + #[derive(Debug, Serialize, Deserialize)] pub struct Config { pub tunnels: Vec<(String, TunnelConfig)>, #[serde(default)] + pub commands: Vec<(String, CommandConfig)>, + #[serde(default)] pub schedules: Vec<(String, ScheduledTaskConfig)>, + #[serde(default)] + pub scripts_dir: Option, + #[serde(default)] + pub scripts_output: Option, #[serde(skip_serializing_if = "Option::is_none")] pub path: Option, } @@ -78,7 +107,11 @@ impl Config { let value: toml::Value = content.parse()?; let config = Self::from_toml_value(value)?; - info!("Loaded {} tunnel configurations", config.tunnels.len()); + info!( + "Loaded {} tunnels, {} commands", + config.tunnels.len(), + config.commands.len() + ); Ok(config) } @@ -94,11 +127,16 @@ impl Config { // Convert Vec back to HashMap for serialization let tunnels_map: std::collections::HashMap = self.tunnels.iter().cloned().collect(); + let commands_map: std::collections::HashMap = + self.commands.iter().cloned().collect(); let schedules_map: std::collections::HashMap = self.schedules.iter().cloned().collect(); let serializable_config = ConfigForSerialization { tunnels: tunnels_map, + commands: commands_map, schedules: schedules_map, + scripts_dir: self.scripts_dir.clone(), + scripts_output: self.scripts_output.clone(), path: self.path.clone(), }; let content = toml::to_string_pretty(&serializable_config)?; @@ -144,6 +182,16 @@ impl Config { .and_then(|v| v.as_str()) .map(|s| s.to_string()); + let scripts_dir = table + .get("scripts_dir") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let scripts_output = table + .get("scripts_output") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let mut tunnels = Vec::new(); if let Some(tunnels_value) = table.get("tunnels") @@ -156,6 +204,22 @@ impl Config { } } + let mut commands = Vec::new(); + + if let Some(commands_value) = table.get("commands") + && let Some(commands_table) = commands_value.as_table() + { + for (key, value) in commands_table { + let command_config: CommandConfig = value.clone().try_into()?; + commands.push((key.clone(), command_config)); + } + } + + // Auto-discover scripts from scripts_dir + if let Some(ref dir) = scripts_dir { + discover_scripts(dir, &mut commands, scripts_output.as_deref()); + } + let mut schedules = Vec::new(); if let Some(tasks_value) = table.get("schedules") @@ -170,7 +234,10 @@ impl Config { Ok(Config { tunnels, + commands, schedules, + scripts_dir, + scripts_output, path, }) } @@ -252,8 +319,98 @@ impl Default for Config { Self { tunnels, + commands: vec![], schedules, + scripts_dir: None, + scripts_output: None, path: None, } } } + +/// Expand `~` or `~/...` to home directory in a path string. +/// Does not expand `~user` paths. +fn expand_tilde(path: &str) -> String { + if (path == "~" || path.starts_with("~/")) + && let Some(home) = dirs::home_dir() + { + return path.replacen('~', &home.to_string_lossy(), 1); + } + path.to_string() +} + +/// Scan `scripts_dir` for `*.sh` files and append them as commands. +/// `output_mode` overrides the default "notify" mode for discovered scripts. +fn discover_scripts( + dir: &str, + commands: &mut Vec<(String, CommandConfig)>, + output_mode: Option<&str>, +) { + use crate::scheduler::capitalize_first; + + let before = commands.len(); + let expanded = expand_tilde(dir); + let dir_path = Path::new(&expanded); + + let mut scripts: Vec<_> = match fs::read_dir(dir_path) { + Ok(entries) => entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "sh")) + .collect(), + Err(e) => { + warn!("Failed to read scripts_dir {}: {}", dir, e); + return; + } + }; + + scripts.sort_by_key(|e| e.file_name()); + + let mut first = true; + for entry in scripts { + let path = entry.path(); + let stem = path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + let name = stem + .split(['-', '_']) + .map(capitalize_first) + .collect::>() + .join(" "); + + let key = format!("script-{}", stem); + + let group_header = if first { + first = false; + Some("Scripts".to_string()) + } else { + None + }; + + let group_icon = if group_header.is_some() { + Some("sf:terminal.fill".to_string()) + } else { + None + }; + + commands.push(( + key, + CommandConfig { + name, + command: "bash".to_string(), + args: vec![path.to_string_lossy().to_string()], + output: Some(output_mode.unwrap_or("notify").to_string()), + separator_after: None, + group_header, + group_icon, + }, + )); + } + + let discovered = commands.len() - before; + if discovered > 0 { + debug!("Discovered {} script(s) from {}", discovered, dir); + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index f5e7e3e..0066943 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod command; pub mod config; pub mod scheduler; pub mod tunnel; diff --git a/core/src/scheduler.rs b/core/src/scheduler.rs index 0fb1925..a5d593b 100644 --- a/core/src/scheduler.rs +++ b/core/src/scheduler.rs @@ -561,8 +561,8 @@ fn ordinal(day: u32) -> String { format!("{day}{suffix}") } -/// Capitalize the first ASCII letter, leaving the rest unchanged. -fn capitalize_first(s: &str) -> String { +/// Capitalize the first letter, leaving the rest unchanged. +pub fn capitalize_first(s: &str) -> String { let mut chars = s.chars(); match chars.next() { Some(first) => first.to_uppercase().collect::() + chars.as_str(),