From a13c9adf8fe52c9e5d86c37711eb8476ef482bd0 Mon Sep 17 00:00:00 2001 From: Nigel <201522880+Page011@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:38:37 +1000 Subject: [PATCH] feat: YASB workspace integration (2.2.1). Astur can now serve the workspace through YASB's existing GlazeWM workspaces widget, showing Astur workspaces. --- Cargo.lock | 182 ++++++- README.md | 10 + THIRD_PARTY_NOTICES.md | 42 ++ crates/astur-config/Cargo.toml | 2 +- crates/astur-config/src/lib.rs | 22 + crates/astur-settings/Cargo.toml | 2 +- crates/astur-settings/src/main.rs | 65 +++ crates/astur/Cargo.toml | 4 +- crates/astur/src/main.rs | 6 + crates/astur/src/yasb.rs | 818 ++++++++++++++++++++++++++++++ integrations/yasb/README.md | 67 +++ integrations/yasb/workspaces.css | 33 ++ integrations/yasb/workspaces.yaml | 17 + packaging/astur.iss | 2 +- 14 files changed, 1256 insertions(+), 16 deletions(-) create mode 100644 crates/astur/src/yasb.rs create mode 100644 integrations/yasb/README.md create mode 100644 integrations/yasb/workspaces.css create mode 100644 integrations/yasb/workspaces.yaml diff --git a/Cargo.lock b/Cargo.lock index 7b091cb..4ebe078 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -212,20 +212,22 @@ dependencies = [ [[package]] name = "astur" -version = "2.2.0" +version = "2.2.1" dependencies = [ "astur-config", "embed-resource", + "serde_json", + "tungstenite", "windows", ] [[package]] name = "astur-config" -version = "2.2.0" +version = "2.2.1" [[package]] name = "astur-settings" -version = "2.2.0" +version = "2.2.1" dependencies = [ "astur-config", "eframe", @@ -482,6 +484,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -626,6 +637,17 @@ dependencies = [ "libc", ] +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -664,6 +686,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.9.4" @@ -723,6 +751,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -754,20 +791,46 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cursor-icon" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", ] [[package]] @@ -1259,6 +1322,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -1439,6 +1503,31 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1575,6 +1664,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jni" version = "0.22.4" @@ -2460,7 +2555,18 @@ checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -2470,7 +2576,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2482,6 +2588,12 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2644,6 +2756,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -2671,8 +2796,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -3097,6 +3233,22 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.10.3", + "sha1 0.11.0", + "thiserror 2.0.18", +] + [[package]] name = "type-map" version = "0.5.1" @@ -3573,7 +3725,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -4095,10 +4247,10 @@ dependencies = [ "hex", "nix", "ordered-stream", - "rand", + "rand 0.8.7", "serde", "serde_repr", - "sha1", + "sha1 0.10.7", "static_assertions", "tracing", "uds_windows", @@ -4244,6 +4396,12 @@ dependencies = [ "syn", ] +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + [[package]] name = "zune-core" version = "0.5.1" diff --git a/README.md b/README.md index d881809..4b1de8e 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,16 @@ missing companion instead of failing silently. lives on the [`lite`](https://github.com/Page011/Astur/tree/lite) branch (a single crate, no workspace). +## YASB integration + +Use YASB's existing GlazeWM workspace widget with Astur's optional localhost +WebSocket server: live workspace indicators, app icons, click-to-switch and wheel +cycling, including per-monitor workspaces. Open **Settings > YASB integration** +to enable it, choose a port, and copy the widget configuration and example CSS. +Alternatively, set `yasb_enabled = true` in `astur.conf` and connect the widget +to `ws://127.0.0.1:6123`. +See [setup and ready-to-merge YASB configuration](integrations/yasb/README.md). + ## How it works Astur installs two low-level Windows hooks (`WH_MOUSE_LL`, `WH_KEYBOARD_LL`) that diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index dad1451..6be5435 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -64,3 +64,45 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +## Rust crates for the YASB WebSocket server (added in 2.2.1) + +The optional YASB integration (`crates/astur/src/yasb.rs`) links these crates +into `astur.exe`. Each is available under MIT (most are dual MIT OR Apache-2.0; +Astur uses them under the MIT terms). Copyright holders, from each crate's own +license file: + +- tungstenite - Copyright (c) 2017 Alexey Galakhov; Copyright (c) 2016 Jason Housley +- http - Copyright (c) 2017 http-rs authors +- httparse - Copyright (c) 2015-2025 Sean McArthur +- data-encoding - Copyright (c) 2015-2020 Julien Cretin; Copyright (c) 2017-2020 Google Inc. +- rand - Copyright 2018 Developers of the Rand project; Copyright (c) 2014 The Rust Project Developers +- rand_core - Copyright (c) 2018-2026 The Rand Project Developers +- sha1, digest, block-buffer, chacha20, const-oid, cpufeatures, crypto-common, + hybrid-array - Copyright (c) The RustCrypto Project Developers (sha1 and digest + also Copyright (c) 2016-2017 Artyom Pavlov; sha1 also Copyright (c) 2006-2013 + Graydon Hoare and Mozilla Foundation) +- serde_json - Erick Tryzelaar and David Tolnay +- itoa, zmij - David Tolnay + +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/astur-config/Cargo.toml b/crates/astur-config/Cargo.toml index 2e0c58f..9d9ed2c 100644 --- a/crates/astur-config/Cargo.toml +++ b/crates/astur-config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "astur-config" -version = "2.2.0" +version = "2.2.1" edition = "2021" description = "Astur configuration parsing — shared by the WM and the settings GUI" license = "Apache-2.0" diff --git a/crates/astur-config/src/lib.rs b/crates/astur-config/src/lib.rs index a5ea7f7..ee17bda 100644 --- a/crates/astur-config/src/lib.rs +++ b/crates/astur-config/src/lib.rs @@ -121,6 +121,9 @@ pub struct Config { pub media_enabled: bool, pub ipc_enabled: bool, pub ipc_pipe: String, + /// GlazeWM-compatible workspace API for YASB, bound to IPv4 loopback only. + pub yasb_enabled: bool, + pub yasb_port: u16, /// Allow the IPC `launch` verb to run arbitrary commands. Off by default: /// scripting a window manager rarely needs arbitrary exec, and leaving it on /// makes Astur a convenient parent process for something else already @@ -344,6 +347,8 @@ impl Config { media_enabled: false, ipc_enabled: false, ipc_pipe: "astur".to_string(), + yasb_enabled: false, + yasb_port: 6123, ipc_allow_launch: false, foreground_lock_disable: true, async_window_pos: true, @@ -730,6 +735,14 @@ ipc_pipe = astur # Astur as the parent process for a command of its choosing. ipc_allow_launch = false +# YASB workspace integration. Uses YASB's existing GlazeWM workspace widget. +# WebSocket server on 127.0.0.1 only; independent of the named-pipe API above. +# Local processes can read workspace/window handles and switch workspaces. +# Browser origins and arbitrary launch commands are rejected. Hot-reloaded. +# Disable Astur's bar in navbar.conf when replacing it with YASB. +yasb_enabled = false +yasb_port = 6123 + # Windows normally refuses to let a background app steal the foreground. Astur # disables that timeout so focus changes land reliably. It is a SYSTEM-WIDE # setting affecting every application, not just Astur; the previous value is put @@ -1539,6 +1552,12 @@ fn parse_into_from(c: &mut Config, text: &str, source: &str) { "wallpaper_dir" => c.wallpaper_dir = v.to_string(), "media_enabled" => c.media_enabled = parse_bool(v), "ipc_enabled" => c.ipc_enabled = parse_bool(v), + "yasb_enabled" => c.yasb_enabled = parse_bool(v), + "yasb_port" => { + if let Ok(port @ 1..=65535) = v.parse::() { + c.yasb_port = port; + } + } "ipc_pipe" => { if !v.is_empty() { c.ipc_pipe = v.to_string() @@ -1919,6 +1938,8 @@ pub fn reload_groups(old: &Config, new: &Config) -> ReloadGroups { media_enabled, ipc_enabled, ipc_pipe, + yasb_enabled, + yasb_port, ipc_allow_launch, foreground_lock_disable, async_window_pos, @@ -2039,6 +2060,7 @@ pub fn reload_groups(old: &Config, new: &Config) -> ReloadGroups { diff!(live: clipboard_history, clipboard_limit, clipboard_prefix, emoji_picker); diff!(live: emoji_prefix, wallpaper_dir, workspace_wallpapers, ipc_enabled, ipc_pipe); diff!(live: ipc_allow_launch, foreground_lock_disable, async_window_pos); + diff!(live: yasb_enabled, yasb_port); diff!(live: persist_state, log_level, unknown_keys, extra_hotkeys, window_rules); diff!(live: ignore_classes, float_classes, key_focus_next, key_focus_prev); diff!(live: key_shrink_master, key_grow_master, key_promote_master); diff --git a/crates/astur-settings/Cargo.toml b/crates/astur-settings/Cargo.toml index fd7e288..152621e 100644 --- a/crates/astur-settings/Cargo.toml +++ b/crates/astur-settings/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "astur-settings" -version = "2.2.0" +version = "2.2.1" edition = "2021" description = "Astur settings GUI — friendly editor for astur.conf / navbar.conf" license = "Apache-2.0" diff --git a/crates/astur-settings/src/main.rs b/crates/astur-settings/src/main.rs index 2c373e7..d7f1610 100644 --- a/crates/astur-settings/src/main.rs +++ b/crates/astur-settings/src/main.rs @@ -18,6 +18,11 @@ use astur_config::{ }; use eframe::egui; +// Embed the shipped examples so the portable settings exe can copy them without +// depending on a repository checkout or separate files beside the executable. +const YASB_WIDGET_YAML: &str = include_str!("../../../integrations/yasb/workspaces.yaml"); +const YASB_WIDGET_CSS: &str = include_str!("../../../integrations/yasb/workspaces.css"); + fn main() -> eframe::Result { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() @@ -44,6 +49,7 @@ enum Section { SystemMenu, Desktop, Bar, + Yasb, Widgets, Hotkeys, Rules, @@ -60,6 +66,7 @@ const SECTIONS: &[(Section, &str)] = &[ (Section::SystemMenu, "System menu"), (Section::Desktop, "Desktop tools"), (Section::Bar, "Bar"), + (Section::Yasb, "YASB integration"), (Section::Widgets, "Bar widgets"), (Section::Hotkeys, "Hotkeys"), (Section::Rules, "Window rules"), @@ -321,6 +328,8 @@ impl App { ("media_enabled", b(c.media_enabled)), ("ipc_enabled", b(c.ipc_enabled)), ("ipc_pipe", c.ipc_pipe.clone()), + ("yasb_enabled", b(c.yasb_enabled)), + ("yasb_port", c.yasb_port.to_string()), ("persist_state", b(c.persist_state)), ("log_level", c.log_level.clone()), ("extra_hotkeys", format_hotkeys(&c.extra_hotkeys)), @@ -555,6 +564,7 @@ impl eframe::App for App { Section::SystemMenu => self.ui_system_menu(ui), Section::Desktop => self.ui_desktop(ui), Section::Bar => self.ui_bar(ui), + Section::Yasb => self.ui_yasb(ui), Section::Widgets => self.ui_widgets(ui), Section::Hotkeys => self.ui_hotkeys(ui), Section::Rules => self.ui_rules(ui), @@ -956,6 +966,61 @@ impl App { }); } + fn ui_yasb(&mut self, ui: &mut egui::Ui) { + heading(ui, "YASB integration"); + ui.label("Show Astur workspaces in YASB, with live indicators, app icons and click or wheel switching."); + ui.add_space(8.0); + ui.checkbox( + &mut self.cfg.yasb_enabled, + "Enable YASB workspace integration", + ); + ui.add_enabled_ui(self.cfg.yasb_enabled, |ui| { + ui.horizontal(|ui| { + ui.label("Port"); + ui.add(egui::DragValue::new(&mut self.cfg.yasb_port).range(1..=65535)) + .on_hover_text( + "Default: 6123. Choose a free port and use the same one in YASB.", + ); + }); + }); + let endpoint = format!("ws://127.0.0.1:{}", self.cfg.yasb_port); + ui.horizontal_wrapped(|ui| { + ui.label("YASB server address:"); + ui.monospace(&endpoint); + if ui.button("Copy address").clicked() { + ui.ctx().copy_text(endpoint.clone()); + } + }); + ui.label(egui::RichText::new("Save applies the integration and port changes live.").weak()); + + heading(ui, "Astur bar"); + ui.checkbox(&mut self.cfg.bar_enabled, "Show Astur's built-in bar"); + ui.label("Turn this off once YASB is ready, then Save. Turn it back on to restore the Astur bar."); + + heading(ui, "Set up YASB"); + ui.label("1. Enable the integration above and click Save."); + ui.label("2. Merge the widget YAML below into YASB's config.yaml. If a widgets section already exists, add astur_workspaces inside it."); + ui.horizontal_wrapped(|ui| { + if ui.button("Copy widget YAML").clicked() { + ui.ctx() + .copy_text(YASB_WIDGET_YAML.replace("ws://127.0.0.1:6123", &endpoint)); + } + if ui.button("Copy example CSS").clicked() { + ui.ctx().copy_text(YASB_WIDGET_CSS.to_string()); + } + }); + ui.label("The copied YAML uses the port shown above. Append the CSS to styles.css, or keep your existing GlazeWM workspace styles."); + ui.collapsing("Preview widget YAML", |ui| { + egui::ScrollArea::horizontal().show(ui, |ui| { + ui.code(YASB_WIDGET_YAML.replace("ws://127.0.0.1:6123", &endpoint)); + }); + }); + ui.label("3. Add astur_workspaces to your bar's widgets.left list. In that bar's window_flags, set windows_app_bar: true to reserve space for YASB."); + ui.label("4. Reload YASB, then turn off the Astur bar above and Save."); + ui.add_space(8.0); + ui.label(egui::RichText::new("Uses YASB's GlazeWM workspace widget. GlazeWM itself must be stopped. Binding-mode and tiling-direction widgets are not supported.").weak()); + } + fn ui_bar(&mut self, ui: &mut egui::Ui) { heading(ui, "Bar"); ui.checkbox(&mut self.cfg.bar_enabled, "Show the status bar"); diff --git a/crates/astur/Cargo.toml b/crates/astur/Cargo.toml index 3f2c14d..512b025 100644 --- a/crates/astur/Cargo.toml +++ b/crates/astur/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "astur" -version = "2.2.0" +version = "2.2.1" edition = "2021" description = "Alt-drag move/resize + tiling window manager for Windows" license = "Apache-2.0" @@ -20,6 +20,8 @@ embed-resource = "2" [dependencies] astur-config = { path = "../astur-config" } +serde_json = "1" +tungstenite = { version = "0.30", default-features = false, features = ["handshake"] } [dependencies.windows] version = "0.58" diff --git a/crates/astur/src/main.rs b/crates/astur/src/main.rs index b49d583..4051f5c 100644 --- a/crates/astur/src/main.rs +++ b/crates/astur/src/main.rs @@ -25,6 +25,7 @@ use std::sync::{Condvar, LazyLock, Mutex, OnceLock}; use std::time::Instant; mod layout; +mod yasb; // Config now lives in the shared `astur-config` crate (the settings GUI parses the // same model). Aliased to `config` so the rest of this file is unchanged. use astur_config as config; @@ -1977,6 +1978,7 @@ enum Cmd { PromoteMaster, ResizeMaster(f32), Switch(usize), + YasbFocus(yasb::Focus), MoveToWs(usize), ToggleTiling, ToggleFloat, @@ -2034,6 +2036,7 @@ impl Cmd { Cmd::PromoteMaster => "PromoteMaster", Cmd::ResizeMaster(_) => "ResizeMaster", Cmd::Switch(_) => "Switch", + Cmd::YasbFocus(_) => "YasbFocus", Cmd::MoveToWs(_) => "MoveToWs", Cmd::ToggleTiling => "ToggleTiling", Cmd::ToggleFloat => "ToggleFloat", @@ -8143,6 +8146,7 @@ unsafe fn process(mgr: &mut Manager, cmd: Cmd) { } show_workspace(mgr, mi, local); } + Cmd::YasbFocus(focus) => yasb::focus_workspace(mgr, focus), Cmd::MoveToWs(i) => { if i >= mgr.cfg.workspaces || !mgr.tiling || mgr.monitors.is_empty() { return; @@ -11015,6 +11019,7 @@ fn manager_loop(cfg: Config) { /// Refresh the shutdown registry and the O(1) locate index from current manager /// state. One walk feeds both, so the index costs nothing extra. fn sync_managed(mgr: &Manager) { + yasb::publish(mgr); let mut all = MANAGED.lock().unwrap(); all.clear(); let mut map: HashMap = HashMap::new(); @@ -16899,6 +16904,7 @@ fn main() { spawn_named("hook-watchdog", hook_watchdog); // Optional local-only named-pipe command API; blocks on its own worker. spawn_named("ipc", ipc_worker); + spawn_named("yasb", yasb::worker); // Crash rescue: un-hide anything a previous (killed) instance left hidden // BEFORE the manager adopts windows, so they're adopted visible. rescue_orphans(); diff --git a/crates/astur/src/yasb.rs b/crates/astur/src/yasb.rs new file mode 100644 index 0000000..0bf8566 --- /dev/null +++ b/crates/astur/src/yasb.rs @@ -0,0 +1,818 @@ +//! Workspace subset of GlazeWM's WebSocket protocol, for unmodified YASB. +//! All sockets and JSON stay on this worker. The manager only publishes model +//! copies when changed and consumes focus commands through its existing queue. + +use std::io; +use std::net::{Ipv4Addr, TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, LazyLock, Mutex}; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; +use tungstenite::handshake::server::{ErrorResponse, Request, Response, ServerHandshake}; +use tungstenite::handshake::MidHandshake; +use tungstenite::protocol::WebSocketConfig; +use tungstenite::{accept_hdr_with_config, Error, HandshakeError, Message, WebSocket}; + +use super::{Cmd, Manager}; + +const MAX_CLIENTS: usize = 16; +const IO_TICK: Duration = Duration::from_millis(16); +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(3); + +#[derive(Debug, PartialEq)] +pub(super) enum Focus { + Workspace { monitor: isize, index: usize }, + Cycle { delta: i32, on_monitor: bool }, +} + +#[derive(Default)] +struct Snapshot { + monitors: Vec, + primary: usize, + focused: usize, + per_monitor: bool, + labels: Vec, +} + +struct MonitorState { + handle: isize, + active: usize, + workspaces: Vec, +} + +struct WorkspaceState { + windows: Vec, + floating: Vec, + focused: isize, +} + +impl Snapshot { + fn matches(&self, mgr: &Manager) -> bool { + self.primary == mgr.primary + && self.focused == mgr.focused_mon + && self.per_monitor == mgr.cfg.per_monitor + && self.labels == mgr.cfg.workspace_names + && self.monitors.len() == mgr.monitors.len() + && self.monitors.iter().zip(&mgr.monitors).all(|(a, b)| { + a.handle == b.hmon + && a.active == b.active + && a.workspaces.len() == b.workspaces.len() + && a.workspaces.iter().zip(&b.workspaces).all(|(a, b)| { + a.windows == b.windows && a.floating == b.floating && a.focused == b.focused + }) + }) + } + + fn from_manager(mgr: &Manager) -> Self { + Self { + primary: mgr.primary, + focused: mgr.focused_mon, + per_monitor: mgr.cfg.per_monitor, + labels: mgr.cfg.workspace_names.clone(), + monitors: mgr + .monitors + .iter() + .map(|m| MonitorState { + handle: m.hmon, + active: m.active, + workspaces: m + .workspaces + .iter() + .map(|w| WorkspaceState { + windows: w.windows.clone(), + floating: w.floating.clone(), + focused: w.focused, + }) + .collect(), + }) + .collect(), + } + } + + fn number(&self, mi: usize, wi: usize) -> usize { + if self.per_monitor { + wi + 1 + } else { + let n = self.monitors.len(); + wi * n + (mi + n - self.primary % n) % n + 1 + } + } + + fn name(&self, mi: usize, wi: usize) -> String { + if self.per_monitor { + // YASB keys buttons by name across ALL monitors. Local "1" alone + // would merge unrelated workspaces. Display names remain friendly. + format!("m{}-w{}", self.monitors[mi].handle, wi + 1) + } else { + self.number(mi, wi).to_string() + } + } + + fn workspace(&self, name: &str) -> Option { + self.monitors.iter().enumerate().find_map(|(mi, m)| { + (0..m.workspaces.len()).find_map(|wi| { + (self.name(mi, wi) == name).then_some(Focus::Workspace { + monitor: m.handle, + index: wi, + }) + }) + }) + } + + fn monitors_json(&self) -> Value { + let monitors: Vec<_> = self + .monitors + .iter() + .enumerate() + .map(|(mi, m)| { + let workspaces: Vec<_> = m + .workspaces + .iter() + .enumerate() + .map(|(wi, w)| { + let number = self.number(mi, wi); + let name = self.name(mi, wi); + let label = self + .labels + .get(number - 1) + .filter(|s| !s.is_empty()) + .cloned() + .unwrap_or_else(|| number.to_string()); + let windows: Vec<_> = w.windows.iter().map(|h| json!({ + "type": "window", "id": format!("window-{h}"), "handle": h, + "title": "", "className": "", "processName": "", + "displayState": if wi == m.active { "shown" } else { "hidden" }, + "state": {"type": if w.floating.contains(h) { "floating" } else { "tiling" }}, + })).collect(); + json!({ + "type": "workspace", "id": name, "name": name, "displayName": label, + "isDisplayed": wi == m.active, + "hasFocus": mi == self.focused && wi == m.active, + "children": windows, + }) + }) + .collect(); + json!({"type": "monitor", "handle": m.handle, + "hardwareId": format!("astur-{}", m.handle), "children": workspaces}) + }) + .collect(); + json!({"monitors": monitors}) + } +} + +#[derive(Default)] +struct Published { + port: u16, // zero = disabled + revision: u64, + snapshot: Arc, +} + +static ENABLED: AtomicBool = AtomicBool::new(false); +static STATE: LazyLock<(Mutex, Condvar)> = + LazyLock::new(|| (Mutex::new(Published::default()), Condvar::new())); + +pub(super) fn publish(mgr: &Manager) { + // No lock, model copy or wakeup on the default (disabled) path. + if !mgr.cfg.yasb_enabled && !ENABLED.load(Ordering::Relaxed) { + return; + } + let mut state = STATE.0.lock().unwrap(); + let port = if mgr.cfg.yasb_enabled { + mgr.cfg.yasb_port + } else { + 0 + }; + if state.port == port && state.snapshot.matches(mgr) { + return; + } + state.port = port; + state.snapshot = Arc::new(Snapshot::from_manager(mgr)); + state.revision = state.revision.wrapping_add(1); + ENABLED.store(port != 0, Ordering::Relaxed); + STATE.1.notify_one(); +} + +/// Runs only on the manager. Relative commands resolve here so rapid scroll +/// commands accumulate, instead of all targeting the same stale snapshot. +pub(super) unsafe fn focus_workspace(mgr: &mut Manager, focus: Focus) { + if !mgr.cfg.yasb_enabled || mgr.monitors.is_empty() { + return; + } + let target = match focus { + Focus::Workspace { monitor, index } => mgr + .mon_by_hmon(monitor) + .filter(|&mi| index < mgr.monitors[mi].workspaces.len()) + .map(|mi| (mi, index)), + Focus::Cycle { delta, on_monitor } => { + let mut mi = mgr.focused_mon; + if on_monitor { + let mut point = super::POINT::default(); + if super::GetCursorPos(&mut point).is_ok() { + let hmon = super::MonitorFromPoint(point, super::MONITOR_DEFAULTTONEAREST); + mi = mgr.mon_by_hmon(hmon.0 as isize).unwrap_or(mi); + } + } + cycle_target(mgr, mi, delta, on_monitor) + } + }; + if let Some((mi, wi)) = target { + super::show_workspace(mgr, mi, wi); + } +} + +fn cycle_target(mgr: &Manager, mi: usize, delta: i32, on_monitor: bool) -> Option<(usize, usize)> { + let monitor = mgr.monitors.get(mi)?; + let mut slots: Vec<_> = mgr + .monitors + .iter() + .enumerate() + .filter(|(i, _)| !on_monitor || *i == mi) + .flat_map(|(i, m)| (0..m.workspaces.len()).map(move |w| (i, w))) + .collect(); + if !mgr.cfg.per_monitor { + slots.sort_by_key(|&(i, w)| mgr.ml_to_global(i, w)); + } + let current = slots + .iter() + .position(|&slot| slot == (mi, monitor.active))?; + let next = (current as i64 + i64::from(delta)).rem_euclid(slots.len() as i64) as usize; + Some(slots[next]) +} + +fn response(command: &str, result: Result) -> Message { + let (data, error) = match result { + Ok(data) => (data, None), + Err(error) => (json!({}), Some(error)), + }; + Message::Text( + json!({ + "messageType": "client_response", "clientMessage": command, + "success": error.is_none(), "error": error, "data": data, + }) + .to_string() + .into(), + ) +} + +fn dispatch( + command: &str, + snapshot: &Snapshot, + monitors: &Value, + subscribed: &mut bool, + enqueue: &mut impl FnMut(Focus), +) -> Message { + let parts: Vec<_> = command.split_whitespace().collect(); + let result = match parts.as_slice() { + ["query", "monitors"] => Ok(monitors.clone()), + ["sub", "-e", events @ ..] + if (events.contains(&"workspace_updated") || events.contains(&"all")) + && events.iter().all(|e| { + matches!( + *e, + "workspace_activated" + | "workspace_deactivated" + | "workspace_updated" + | "focus_changed" + | "focused_container_moved" + | "all" + ) + }) => + { + *subscribed = true; + Ok(json!({"subscriptionId": "astur-workspaces"})) + } + ["unsub", "astur-workspaces"] => { + *subscribed = false; + Ok(json!({})) + } + ["command", "focus", "--workspace", name] => match snapshot.workspace(name) { + Some(focus) => { + enqueue(focus); + Ok(json!({})) + } + None => Err("unknown workspace"), + }, + ["command", "focus", flag] => { + let cycle = match *flag { + "--next-active-workspace-on-monitor" => Some((1, true)), + "--prev-active-workspace-on-monitor" => Some((-1, true)), + "--next-active-workspace" => Some((1, false)), + "--prev-active-workspace" => Some((-1, false)), + _ => None, + }; + match cycle { + Some((delta, on_monitor)) => { + enqueue(Focus::Cycle { delta, on_monitor }); + Ok(json!({})) + } + None => Err("unsupported focus command"), + } + } + // YASB sends these after every event even for a workspace-only widget. + ["query", "binding-modes"] => Ok(json!({"bindingModes": []})), + _ => Err("Astur supports workspace queries, subscriptions and focus commands only"), + }; + response(command, result) +} + +type HeaderCheck = fn(&Request, Response) -> Result; +type Pending = MidHandshake>; + +// Qt's native QWebSocket sends no Origin by default. Browsers always do: reject +// them, including localhost pages, so visiting a site cannot control the WM. +#[allow(clippy::result_large_err)] +fn check_origin(request: &Request, response: Response) -> Result { + if request.headers().contains_key("origin") { + let mut denied = ErrorResponse::new(Some("Browser origins are not allowed".into())); + *denied.status_mut() = tungstenite::http::StatusCode::FORBIDDEN; + Err(denied) + } else { + Ok(response) + } +} + +enum Connection { + Handshake(Pending), + Open { + socket: WebSocket, + subscribed: bool, + revision: u64, + }, +} + +struct Peer { + connection: Option, + connected: Instant, +} + +fn handshake_result( + result: Result, HandshakeError>>, +) -> Option { + match result { + Ok(socket) => Some(Connection::Open { + socket, + subscribed: false, + revision: 0, + }), + Err(HandshakeError::Interrupted(pending)) => Some(Connection::Handshake(pending)), + Err(HandshakeError::Failure(_)) => None, + } +} + +fn pending_io(result: Result<(), Error>) -> bool { + match result { + Ok(()) => true, + Err(Error::Io(e)) => e.kind() == io::ErrorKind::WouldBlock, + Err(_) => false, + } +} + +impl Peer { + fn tick( + &mut self, + revision: u64, + snapshot: &Snapshot, + monitors: &Value, + enqueue: &mut impl FnMut(Focus), + ) -> bool { + let Some(connection) = self.connection.take() else { + return false; + }; + self.connection = match connection { + Connection::Handshake(pending) => { + if self.connected.elapsed() >= HANDSHAKE_TIMEOUT { + return false; + } + handshake_result(pending.handshake()) + } + Connection::Open { + mut socket, + mut subscribed, + revision: old_revision, + } => { + let was_subscribed = subscribed; + if !pending_io(socket.flush()) { + return false; + } + // Bounded work per peer: a chatty client cannot starve others. + for _ in 0..16 { + match socket.read() { + Ok(Message::Text(text)) => { + let reply = + dispatch(&text, snapshot, monitors, &mut subscribed, enqueue); + if !pending_io(socket.send(reply)) { + return false; + } + } + Ok(Message::Close(_)) => { + let _ = socket.flush(); + return false; + } + Ok(Message::Ping(_) | Message::Pong(_)) => {} + Ok(_) => return false, + Err(Error::Io(e)) if e.kind() == io::ErrorKind::WouldBlock => break, + Err(_) => return false, + } + } + if was_subscribed && subscribed && revision != old_revision { + let event = Message::Text(json!({ + "messageType": "event_subscription", "subscriptionId": "astur-workspaces", + "success": true, "error": null, "data": {"eventType": "workspace_updated"}, + }).to_string().into()); + if !pending_io(socket.send(event)) { + return false; + } + } + self.connection = Some(Connection::Open { + socket, + subscribed, + revision, + }); + return true; + } + }; + self.connection.is_some() + } +} + +struct Server { + listener: TcpListener, + peers: Vec, +} + +impl Server { + fn bind(port: u16) -> io::Result { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, port))?; + listener.set_nonblocking(true)?; + Ok(Self { + listener, + peers: Vec::new(), + }) + } + + fn tick( + &mut self, + revision: u64, + snapshot: &Snapshot, + monitors: &Value, + mut enqueue: impl FnMut(Focus), + ) { + for _ in 0..MAX_CLIENTS { + let Ok((stream, _)) = self.listener.accept() else { + break; + }; + if self.peers.len() >= MAX_CLIENTS || stream.set_nonblocking(true).is_err() { + continue; + } + let _ = stream.set_nodelay(true); + let config = WebSocketConfig::default() + .read_buffer_size(4096) + .write_buffer_size(0) + .max_write_buffer_size(1024 * 1024) + .max_message_size(Some(4096)) + .max_frame_size(Some(4096)); + let connection = handshake_result(accept_hdr_with_config( + stream, + check_origin as HeaderCheck, + Some(config), + )); + if connection.is_some() { + self.peers.push(Peer { + connection, + connected: Instant::now(), + }); + } + } + self.peers + .retain_mut(|p| p.tick(revision, snapshot, monitors, &mut enqueue)); + } +} + +pub(super) fn worker() { + let mut server: Option = None; + let mut bound_port = 0; + let mut cached_revision = 0; + let mut monitors = json!({"monitors": []}); + let mut failed_port = None; + loop { + let (port, revision, snapshot) = { + let mut state = STATE.0.lock().unwrap(); + if state.port == 0 { + server = None; // Close listeners and clients on hot disable. + bound_port = 0; + state = STATE.1.wait_while(state, |s| s.port == 0).unwrap(); + } + (state.port, state.revision, Arc::clone(&state.snapshot)) + }; + if bound_port != port { + server = None; + bound_port = 0; + match Server::bind(port) { + Ok(new) => { + server = Some(new); + bound_port = port; + failed_port = None; + } + Err(error) => { + if failed_port != Some(port) { + if super::log_on(super::LOG_ERROR) { + super::log_push( + super::LOG_ERROR, + &format!("YASB cannot bind 127.0.0.1:{port}: {error}"), + ); + } + failed_port = Some(port); + } + let state = STATE.0.lock().unwrap(); + let _ = STATE + .1 + .wait_timeout_while(state, Duration::from_secs(2), |s| s.port == port); + continue; + } + } + } + if revision != cached_revision { + monitors = snapshot.monitors_json(); + cached_revision = revision; + } + if let Some(server) = server.as_mut() { + server.tick(revision, &snapshot, &monitors, |focus| { + super::push_cmd(Cmd::YasbFocus(focus)) + }); + } + let state = STATE.0.lock().unwrap(); + // Model changes wake immediately. The short timeout services sockets; + // no manager polling, processes spawned, or socket work on input paths. + let wait = if server.as_ref().is_some_and(|s| s.peers.is_empty()) { + Duration::from_millis(100) + } else { + IO_TICK + }; + let _ = STATE + .1 + .wait_timeout_while(state, wait, |s| s.revision == revision); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + use tungstenite::client::IntoClientRequest; + + fn manager(per_monitor: bool) -> Manager { + let mut mgr = Manager { + monitors: (0..2) + .map(|i| super::super::Monitor::new(0x1000 + i, super::super::RECT::default(), 3)) + .collect(), + primary: 1, + focused_mon: 1, + tiling: true, + cfg: super::super::Config::defaults(), + pending_launch_mon: 0, + }; + mgr.cfg.per_monitor = per_monitor; + mgr.cfg.workspace_names = vec!["Code \"main\"".into(), "Chat".into()]; + mgr.monitors[1].active = 1; + mgr.monitors[1].workspaces[1].windows = vec![123, 456]; + mgr.monitors[1].workspaces[1].floating = vec![456]; + mgr + } + + #[test] + fn model_matches_yasb_names_handles_occupancy_and_focus() { + let mut mgr = manager(true); + let state = Snapshot::from_manager(&mgr); + let data = state.monitors_json(); + let monitors = data["monitors"].as_array().unwrap(); + let first = &monitors[0]["children"][0]; + let second = &monitors[1]["children"][0]; + assert_ne!( + first["name"], second["name"], + "YASB uses globally unique keys" + ); + assert_eq!(first["displayName"], "Code \"main\""); + assert_eq!(first["displayName"], second["displayName"]); + assert_eq!(monitors[1]["handle"], 0x1001); + let focused = &monitors[1]["children"][1]; + assert_eq!(focused["hasFocus"], true); + assert_eq!(focused["isDisplayed"], true); + assert_eq!(focused["children"].as_array().unwrap().len(), 2); + assert_eq!(focused["children"][0]["handle"], 123); + assert_eq!(focused["children"][1]["state"]["type"], "floating"); + assert_eq!( + state.workspace(focused["name"].as_str().unwrap()), + Some(Focus::Workspace { + monitor: 0x1001, + index: 1 + }) + ); + assert!(state.matches(&mgr)); + mgr.monitors[1].workspaces[1].windows.pop(); + assert!(!state.matches(&mgr)); + let shared = Snapshot::from_manager(&manager(false)); + assert_eq!(shared.name(1, 0), "1", "primary owns workspace 1"); + assert_eq!(shared.name(0, 0), "2"); + assert_eq!(shared.name(1, 1), "3"); + assert_eq!(cycle_target(&manager(false), 1, 1, false), Some((0, 1))); + assert_eq!(cycle_target(&manager(false), 1, -1, false), Some((0, 0))); + assert_eq!(cycle_target(&manager(true), 0, -1, true), Some((0, 2))); + assert_eq!(cycle_target(&manager(true), 1, 1, true), Some((1, 2))); + } + + #[test] + fn commands_are_strict_and_only_enqueue_workspace_actions() { + let state = Snapshot::from_manager(&manager(false)); + let monitors = state.monitors_json(); + let mut subscribed = false; + let mut commands = Vec::new(); + for bad in [ + "command focus --workspace 0", + "command focus --workspace 999", + "command focus --workspace 1 extra", + "command shell calc.exe", + "launch calc.exe", + "command toggle-tiling-direction", + "sub -e unsupported", + "query monitors extra", + ] { + let reply = dispatch(bad, &state, &monitors, &mut subscribed, &mut |f| { + commands.push(f) + }); + let reply: Value = serde_json::from_str(reply.to_text().unwrap()).unwrap(); + assert_eq!(reply["success"], false, "{bad}"); + } + assert!(commands.is_empty()); + assert!(!subscribed); + for command in [ + "command focus --workspace 1", + "command focus --next-active-workspace-on-monitor", + "command focus --prev-active-workspace", + ] { + let reply = dispatch(command, &state, &monitors, &mut subscribed, &mut |f| { + commands.push(f) + }); + let reply: Value = serde_json::from_str(reply.to_text().unwrap()).unwrap(); + assert_eq!(reply["success"], true); + } + assert_eq!( + commands, + vec![ + Focus::Workspace { + monitor: 0x1001, + index: 0 + }, + Focus::Cycle { + delta: 1, + on_monitor: true + }, + Focus::Cycle { + delta: -1, + on_monitor: false + } + ] + ); + } + + struct TestServer { + address: std::net::SocketAddr, + stop: Arc, + join: Option>, + update: mpsc::Sender, + commands: mpsc::Receiver, + } + + impl TestServer { + fn start() -> Self { + let mut server = Server::bind(0).unwrap(); + let address = server.listener.local_addr().unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let running = Arc::clone(&stop); + let (update, updates) = mpsc::channel(); + let (commands_tx, commands) = mpsc::channel(); + let join = std::thread::spawn(move || { + let mut snapshot = Snapshot::from_manager(&manager(false)); + let mut monitors = snapshot.monitors_json(); + let mut revision = 1; + while !running.load(Ordering::Relaxed) { + if let Ok(next) = updates.try_recv() { + snapshot = next; + monitors = snapshot.monitors_json(); + revision += 1; + } + server.tick(revision, &snapshot, &monitors, |f| { + let _ = commands_tx.send(f); + }); + std::thread::sleep(Duration::from_millis(2)); + } + }); + Self { + address, + stop, + join: Some(join), + update, + commands, + } + } + + fn connect(&self) -> WebSocket { + let stream = TcpStream::connect(self.address).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(3))) + .unwrap(); + tungstenite::client(format!("ws://{}", self.address), stream) + .unwrap() + .0 + } + } + + impl Drop for TestServer { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + self.join.take().unwrap().join().unwrap(); + } + } + + fn read_json(socket: &mut WebSocket) -> Value { + serde_json::from_str(socket.read().unwrap().to_text().unwrap()).unwrap() + } + + #[test] + fn real_websocket_yasb_queries_subscribes_switches_and_reconnects() { + let server = TestServer::start(); + // An incomplete handshake must not hold up a real YASB connection. + let _slow_handshake = TcpStream::connect(server.address).unwrap(); + let mut socket = server.connect(); + socket.send(Message::text("sub -e workspace_activated workspace_deactivated workspace_updated focus_changed focused_container_moved")).unwrap(); + socket.send(Message::text("query monitors")).unwrap(); + let ack = read_json(&mut socket); + assert_eq!(ack["success"], true); + assert_eq!(ack["data"]["subscriptionId"], "astur-workspaces"); + let query = read_json(&mut socket); + assert_eq!(query["clientMessage"], "query monitors"); + assert_eq!(query["data"]["monitors"].as_array().unwrap().len(), 2); + + socket + .send(Message::text("command focus --workspace 2")) + .unwrap(); + assert_eq!(read_json(&mut socket)["success"], true); + assert_eq!( + server + .commands + .recv_timeout(Duration::from_secs(3)) + .unwrap(), + Focus::Workspace { + monitor: 0x1000, + index: 0 + } + ); + let mut changed = manager(false); + changed.focused_mon = 0; + server + .update + .send(Snapshot::from_manager(&changed)) + .unwrap(); + assert_eq!(read_json(&mut socket)["messageType"], "event_subscription"); + socket.send(Message::text("query monitors")).unwrap(); + assert_eq!( + read_json(&mut socket)["data"]["monitors"][0]["children"][0]["hasFocus"], + true + ); + // No duplicate events when state is unchanged: next reply is the ping. + socket.send(Message::Ping(vec![1, 2, 3].into())).unwrap(); + assert_eq!(socket.read().unwrap(), Message::Pong(vec![1, 2, 3].into())); + socket.close(None).unwrap(); + let mut reconnected = server.connect(); + reconnected.send(Message::text("query monitors")).unwrap(); + assert_eq!(read_json(&mut reconnected)["success"], true); + } + + #[test] + fn browser_origin_rejected_and_port_conflicts_do_not_panic() { + let server = TestServer::start(); + assert!(Server::bind(server.address.port()).is_err()); + let stream = TcpStream::connect(server.address).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut request = format!("ws://{}", server.address) + .into_client_request() + .unwrap(); + request + .headers_mut() + .insert("origin", "http://localhost".parse().unwrap()); + match tungstenite::client(request, stream) { + Err(HandshakeError::Failure(Error::Http(response))) => { + assert_eq!(response.status(), 403) + } + _ => panic!("browser origin was not rejected"), + } + let mut socket = server.connect(); + socket.send(Message::text("query monitors")).unwrap(); + assert_eq!(read_json(&mut socket)["success"], true); + } +} diff --git a/integrations/yasb/README.md b/integrations/yasb/README.md new file mode 100644 index 0000000..b78af10 --- /dev/null +++ b/integrations/yasb/README.md @@ -0,0 +1,67 @@ +# YASB workspaces + +Astur can serve the workspace part of GlazeWM's localhost WebSocket protocol. +Use YASB's existing `glazewm.workspaces.GlazewmWorkspacesWidget`; no YASB fork, +GlazeWM process, helper script, or custom plugin is required. + +## Setup + +In **Astur Settings > YASB integration**, enable the integration, choose the port, +and click **Save**. The same page can copy the server address, widget YAML with +your selected port, and example CSS. Follow its YASB setup steps, then turn off +**Show Astur's built-in bar** and save. To restore the Astur bar, turn it back on. + +The settings exe embeds the examples, so these copy buttons work with a portable +installation too. For manual configuration: + +1. Run an Astur build containing this integration. In + `%USERPROFILE%\.astur\astur.conf`, add: + + ```ini + yasb_enabled = true + yasb_port = 6123 + ``` + +2. Disable Astur's built-in bar in `%USERPROFILE%\.astur\navbar.conf`: + + ```ini + enabled = false + ``` + + Both files hot-reload. `ipc_enabled` is separate and can stay false. + +3. Merge the widget definition in [workspaces.yaml](workspaces.yaml) into your + YASB configuration. Add `astur_workspaces` to your bar's `widgets.left` list. + In that bar's `window_flags`, set `windows_app_bar: true`. This reserves space; + Astur responds to Windows work-area changes and tiles below/above the bar. + Existing GlazeWM workspace CSS works; the example CSS supplies a basic style. + +4. Restart/reload YASB. Use `ws://127.0.0.1:6123` explicitly. If another app owns + port 6123, choose a free port in **both** Astur and the widget configuration. + Stop GlazeWM if it is running: two window managers must not manage the desktop. + +## Supported behavior + +- Live active, focused, occupied, and empty workspace indicators. +- Configured workspace labels and optional app icons, including floating-window + filtering. YASB reads icons from the real window handles. +- Click to focus a workspace; wheel to cycle. With `monitor_exclusive: true`, + wheel targets the monitor under the cursor. With `false`, wheel cycles globally. +- Shared workspace numbering and per-monitor workspaces. The latter use unique + internal names (`m-w`) while displaying friendly labels. +- Reconnect after restarting Astur, and hot enable/disable/port changes. + +Only the workspace widget is supported. This is not a full GlazeWM API: binding +modes, tiling-direction controls, arbitrary commands, and window title/process +metadata are not exposed. Unsupported commands return a failure response. + +The server binds **127.0.0.1 only**, rejects browser Origin headers, and caps +clients/messages/buffers. Like GlazeWM's local WebSocket model, native local +processes can connect without a token, read workspace/window handles, and switch +workspaces. This is separate from Astur's owner-only named pipe. Do not proxy the +port to other machines. Binding errors are recorded in Astur's log. + +Protocol checked against [YASB's client](https://github.com/amnweb/yasb/blob/5520b42b769d0136929a33eab5460d19f4f3f405/src/core/widgets/services/glazewm/client.py). +Loopback protocol tests cover subscriptions, state updates, switching commands, +reconnects, incomplete handshakes, and browser rejection. Visual styling and +mixed-monitor behavior should also be checked in your installed YASB version. diff --git a/integrations/yasb/workspaces.css b/integrations/yasb/workspaces.css new file mode 100644 index 0000000..e111eec --- /dev/null +++ b/integrations/yasb/workspaces.css @@ -0,0 +1,33 @@ +/* Append to YASB styles.css if no GlazeWM workspace styles exist yet. */ +.glazewm-workspaces .ws-btn { + color: #a6adc8; + background-color: transparent; + border: none; + border-radius: 6px; + padding: 0 8px; + margin: 3px 2px; +} +.glazewm-workspaces .ws-btn.populated { + color: #cdd6f4; +} +.glazewm-workspaces .ws-btn.active_empty, +.glazewm-workspaces .ws-btn.active_populated { + background-color: #313244; + color: #cdd6f4; +} +.glazewm-workspaces .ws-btn.focused_empty, +.glazewm-workspaces .ws-btn.focused_populated { + background-color: #89b4fa; + color: #1e1e2e; +} +.glazewm-workspaces .ws-btn .label { + color: #cdd6f4; +} +.glazewm-workspaces .ws-btn.focused_empty .label, +.glazewm-workspaces .ws-btn.focused_populated .label { + color: #1e1e2e; +} +.glazewm-workspaces .offline-status { + color: #a6adc8; + padding: 0 8px; +} diff --git a/integrations/yasb/workspaces.yaml b/integrations/yasb/workspaces.yaml new file mode 100644 index 0000000..70313fc --- /dev/null +++ b/integrations/yasb/workspaces.yaml @@ -0,0 +1,17 @@ +# Merge into YASB config.yaml. Add astur_workspaces to your bar's widgets.left. +# In that bar's window_flags, set windows_app_bar: true. +widgets: + astur_workspaces: + type: "glazewm.workspaces.GlazewmWorkspacesWidget" + options: + glazewm_server_uri: "ws://127.0.0.1:6123" + offline_label: "Astur offline" + hide_if_offline: false + monitor_exclusive: true + enable_scroll_switching: true + reverse_scroll_direction: false + app_icons: + enabled_populated: true + enabled_active: true + size: 16 + max_icons: 4 diff --git a/packaging/astur.iss b/packaging/astur.iss index ab4a17e..723fdc1 100644 --- a/packaging/astur.iss +++ b/packaging/astur.iss @@ -12,7 +12,7 @@ ; (The hawk .ico is embedded in both exes via build.rs + embed-resource.) #define AppName "Astur" -#define AppVersion "2.2.0" +#define AppVersion "2.2.1" #define AppPublisher "Astur" #define AppURL "https://astur.app" #define AppExe "Astur.exe"