From e4e4d3940579049e9daca55ef9b5516ae864dc28 Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 29 Aug 2026 23:51:27 +0200 Subject: [PATCH] =?UTF-8?q?Fixa=20omladdningsloopen=20+=20churn;=20CI/sl?= =?UTF-8?q?=C3=A4pp=20flyttat=20till=20x64-byggnoden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KRITISKT: notify-watchern reagerade på Access-events, så vår egen läsning av configen triggade ny omladdning i all oändlighet (var 155:e ms). Panelen revs och byggdes om konstant → blinkande hover, klick som aldrig landade. Nu filtreras eventen till riktiga skrivningar (Modify/Create/Remove/Close-Write). - Titeländringar (webbläsare/terminaler) bygger inte längre om UI:t; tooltips slås i stället upp färskt vid hover (query-tooltip), och klick/skroll/menyer agerar på färskt fönster-state vid eventtillfället. - hotspots::rebuild är idempotent (skippar om config+skärmar oförändrade). - Settings: markup-escape i rubriker (Kant & skugga-varningen). - CI: check + release körs på byggnoden brasse-linux01 (runs-on linux-amd64) — waydock byggs BARA för x86_64, aldrig på Pi5:ns arm-runner. Ny release.yaml: vX.Y.Z-tagg bygger x64-binären och laddar upp till Gitea-släppsidan + rullande latest. release.sh är nu bara tagga-och-pusha. Co-Authored-By: Claude Fable 5 --- .gitea/workflows/check.yaml | 7 ++- .gitea/workflows/release.yaml | 101 ++++++++++++++++++++++++++++++++++ README.md | 9 ++- doc/plan.md | 11 ++-- release.sh | 46 +++------------- src/config.rs | 20 +++++-- src/dock.rs | 69 ++++++++++++++++++----- src/hotspots.rs | 16 ++++++ src/settings.rs | 2 +- src/toplevel.rs | 19 ++++++- 10 files changed, 230 insertions(+), 70 deletions(-) create mode 100644 .gitea/workflows/release.yaml diff --git a/.gitea/workflows/check.yaml b/.gitea/workflows/check.yaml index 46a24e3..e36f248 100644 --- a/.gitea/workflows/check.yaml +++ b/.gitea/workflows/check.yaml @@ -1,7 +1,8 @@ name: check -# Kompilerings- och lintvakt på Pi5-runnern (arm64). Bygger inga -# artefakter — släpp byggs lokalt på brasse-linux01 via ./release.sh. +# Kompilerings- och lintvakt på byggnoden på brasse-linux01 (x64-runnern +# med exklusiva labeln linux-amd64). waydock byggs BARA för x86_64 — +# aldrig på Pi5:ns arm-runner. on: push: branches: [master] @@ -14,7 +15,7 @@ on: jobs: check: - runs-on: ubuntu-latest + runs-on: linux-amd64 steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..053abdc --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,101 @@ +name: release + +# Släppbygge på byggnoden på brasse-linux01 (x64-runnern, label +# linux-amd64). En vX.Y.Z-tagg ger en versionerad release + uppdaterar +# rullande "latest". Bara linux-x64 byggs — waydock är en desktopapp +# för brasse-linux01, ingen arm-build. +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Release-tag (lämna tom för 'latest')" + required: false + +jobs: + build-release: + runs-on: linux-amd64 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Bestäm tag + id: t + run: | + case "$GITHUB_REF" in + refs/tags/*) T="$GITHUB_REF_NAME" ;; + *) T="${{ inputs.tag }}"; [ -z "$T" ] && T="latest" ;; + esac + echo "tag=$T" >> "$GITHUB_OUTPUT" + echo "bygger release: $T" + + - name: Install toolchain + GTK4 deps + run: | + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq curl build-essential pkg-config git jq \ + libgtk-4-dev meson ninja-build wayland-protocols libwayland-dev + if ! apt-get install -y -qq libgtk4-layer-shell-dev; then + git clone --depth 1 https://github.com/wmww/gtk4-layer-shell.git /tmp/g4ls + meson setup /tmp/g4ls/build /tmp/g4ls \ + -Dintrospection=false -Dvapi=false -Dexamples=false \ + -Ddocs=false -Dtests=false + ninja -C /tmp/g4ls/build install + ldconfig + arch_dir=$(gcc -dumpmachine) + echo "PKG_CONFIG_PATH=/usr/local/lib/${arch_dir}/pkgconfig:/usr/local/lib/pkgconfig" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=/usr/local/lib/${arch_dir}:/usr/local/lib" >> "$GITHUB_ENV" + fi + curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - name: Bygg x64-binären + run: | + cargo build --release --locked + cargo test --locked + mkdir -p out + cp target/release/waydock "out/waydock-${{ steps.t.outputs.tag }}-linux-x64" + cp target/release/waydock out/waydock-latest-linux-x64 + cp install.sh out/install.sh + cd out && sha256sum waydock-* install.sh > checksums.txt && ls -la + + - name: Skapa Gitea-release + ladda upp assets + env: + API: ${{ github.server_url }}/api/v1 + REPO: ${{ github.repository }} + TAG: ${{ steps.t.outputs.tag }} + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BODY="waydock $TAG — linux-x64. Installera: curl -fsSL https://gitea.brasse-pc.eu/brasse/waydock/raw/branch/master/install.sh | sh" + publish() { # $1 = releasetagg, $2... = assetfiler + local tag=$1; shift + local rid + rid=$(curl -s -X POST "$API/repos/$REPO/releases" \ + -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \ + -d "{\"tag_name\":\"$tag\",\"name\":\"waydock $tag\",\"body\":\"$BODY\"}" | jq -r '.id // empty') + [ -z "$rid" ] && rid=$(curl -s "$API/repos/$REPO/releases/tags/$tag" \ + -H "Authorization: token $TOKEN" | jq -r '.id') + echo "release $tag -> id $rid" + for f in "$@"; do + local aid + aid=$(curl -s "$API/repos/$REPO/releases/$rid/assets" \ + -H "Authorization: token $TOKEN" | jq -r ".[] | select(.name==\"$f\") | .id") + if [ -n "$aid" ] && [ "$aid" != "null" ]; then + curl -s -X DELETE "$API/repos/$REPO/releases/$rid/assets/$aid" \ + -H "Authorization: token $TOKEN" -o /dev/null + fi + curl -s -X POST "$API/repos/$REPO/releases/$rid/assets?name=$f" \ + -H "Authorization: token $TOKEN" -F "attachment=@out/$f" \ + -o /dev/null -w " $f -> HTTP %{http_code}\n" + done + } + if [ "$TAG" != "latest" ]; then + publish "$TAG" "waydock-$TAG-linux-x64" install.sh checksums.txt + fi + # rullande latest: flytta taggen till HEAD och publicera om + git config user.name gitea-actions + git config user.email actions@gitea.brasse-pc.eu + git tag -f latest + git push -f origin latest || echo "kunde inte flytta latest-taggen (fortsätter ändå)" + publish latest waydock-latest-linux-x64 install.sh checksums.txt diff --git a/README.md b/README.md index 7dcad78..dd754e6 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,12 @@ scale/expo-toggle och fönstergeometri till intelligent autohide. ## Släpp ```bash -./release.sh v0.1.0 # taggar, bygger x64, skapar Gitea-release + latest +./release.sh v0.1.0 # taggar och pushar — CI bygger och publicerar ``` -CI på Gitea (Pi5) kör `cargo check`/`clippy`/`test` vid varje push. +All CI kör på **byggnoden på brasse-linux01** (x64-runnern, label +`linux-amd64`) — waydock byggs bara för x86_64, aldrig på Pi5:ns +arm-runner. Push till master kör `cargo check`/`clippy`/`test`; en +vX.Y.Z-tagg bygger release-binären och laddar upp den till +[släppsidan](https://gitea.brasse-pc.eu/brasse/waydock/releases) +(+ rullande `latest`). diff --git a/doc/plan.md b/doc/plan.md index 06054e0..11167f0 100644 --- a/doc/plan.md +++ b/doc/plan.md @@ -26,11 +26,12 @@ inbyggt settings-GUI, med live-uppdatering åt båda hållen. Pattern A (binär), med lokalt släppbygge: -- **CI (Gitea Actions, Pi5/arm64):** `cargo check` + `clippy` + `test` - vid push till master — kompilerings-/lintvakt, inga artefakter. -- **Släpp:** `./release.sh vX.Y.Z` på brasse-linux01 — taggar, bygger - x64-release, skapar Gitea-release med binären + uppdaterar rullande - `latest`. `install.sh` ger en-rads-install via curl. +- **CI (Gitea Actions, x64-runnern `linux-amd64` på brasse-linux01):** + `cargo check` + `clippy` + `test` vid push till master. Bara x86_64 — + ingen arm-build (Pi5-runnern rör aldrig det här repot). +- **Släpp:** `./release.sh vX.Y.Z` taggar och pushar; release-workflowet + på byggnoden bygger x64-binären och laddar upp till Gitea-släppsidan + + uppdaterar rullande `latest`. `install.sh` ger en-rads-install. ## Config diff --git a/release.sh b/release.sh index 6277b4c..76adf02 100644 --- a/release.sh +++ b/release.sh @@ -1,56 +1,24 @@ #!/usr/bin/env bash -# Lokalt släppbygge för waydock (körs på brasse-linux01, x64). +# Släpp waydock: taggar vX.Y.Z och pushar — själva bygget och uppladdningen +# till Gitea-releasen görs av CI-jobbet .gitea/workflows/release.yaml på +# byggnoden (x64-runnern på brasse-linux01). # ./release.sh v0.2.0 -# Taggar, bygger release-binären, skapar Gitea-release med binären som -# asset och uppdaterar den rullande "latest"-releasen. set -euo pipefail TAG="${1:?användning: ./release.sh vX.Y.Z}" [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "taggen ska vara vX.Y.Z"; exit 1; } -OWNER=brasse REPO=waydock -API=https://gitea.brasse-pc.eu/api/v1 -TOKEN="${GITEA_TOKEN:-$(jq -r .token ~/.config/giteactl/config.json)}" -BIN=target/release/waydock -ASSET="waydock-${TAG}-linux-x64" - # versionen i Cargo.toml ska matcha taggen CARGO_VER=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2) [[ "v$CARGO_VER" == "$TAG" ]] || { echo "Cargo.toml har $CARGO_VER, taggen är $TAG — uppdatera först"; exit 1; } -echo "==> bygger $TAG" +echo "==> snabbkoll innan tagg" cargo build --release --locked cargo test --locked -echo "==> taggar + pushar" +echo "==> taggar + pushar (CI bygger och publicerar releasen)" git tag -a "$TAG" -m "waydock $TAG" git push origin master "$TAG" -api() { # method path [json|@fil] - local m=$1 p=$2; shift 2 - curl -fsS -X "$m" -H "Authorization: token $TOKEN" "$@" "$API$p" -} - -make_release() { # tag namn prerelease - api POST "/repos/$OWNER/$REPO/releases" \ - -H 'Content-Type: application/json' \ - -d "{\"tag_name\":\"$1\",\"name\":\"$2\",\"body\":\"waydock $TAG — linux-x64.\\n\\nInstallera:\\n\`\`\`\\ncurl -fsSL https://gitea.brasse-pc.eu/brasse/waydock/raw/branch/master/install.sh | sh\\n\`\`\`\"}" -} - -upload() { # release-id filnamn - api POST "/repos/$OWNER/$REPO/releases/$1/assets?name=$2" \ - -F "attachment=@$BIN;filename=$2" > /dev/null -} - -echo "==> skapar release $TAG" -REL_ID=$(make_release "$TAG" "waydock $TAG" | jq .id) -upload "$REL_ID" "$ASSET" - -echo "==> uppdaterar rullande latest" -OLD=$(api GET "/repos/$OWNER/$REPO/releases/tags/latest" 2>/dev/null | jq -r .id) || OLD="" -[[ -n "$OLD" && "$OLD" != null ]] && api DELETE "/repos/$OWNER/$REPO/releases/$OLD" || true -git tag -f latest && git push -f origin latest -LATEST_ID=$(make_release "latest" "waydock latest ($TAG)" | jq .id) -upload "$LATEST_ID" "waydock-latest-linux-x64" - -echo "==> klart: https://gitea.brasse-pc.eu/$OWNER/$REPO/releases" +echo "==> följ bygget: giteactl runs waydock" +echo "==> releaser: https://gitea.brasse-pc.eu/brasse/waydock/releases" diff --git a/src/config.rs b/src/config.rs index ad516a0..90474ea 100644 --- a/src/config.rs +++ b/src/config.rs @@ -389,11 +389,23 @@ pub fn watch(path: &Path, on_change: Rc) { let dir = path.parent().unwrap_or(Path::new(".")).to_path_buf(); let mut watcher = match notify::recommended_watcher(move |res: notify::Result| { + use notify::event::{AccessKind, AccessMode, EventKind}; if let Ok(ev) = res { - let hit = ev - .paths - .iter() - .any(|p| p.file_name().map(|s| s.to_os_string()) == file_name); + // VIKTIGT: bara riktiga skrivningar. Access-events (öppna/läsa) + // måste ignoreras — annars triggar vår egen läsning en ny + // omladdning i all oändlighet. + let relevant = matches!( + ev.kind, + EventKind::Modify(_) + | EventKind::Create(_) + | EventKind::Remove(_) + | EventKind::Access(AccessKind::Close(AccessMode::Write)) + ); + let hit = relevant + && ev + .paths + .iter() + .any(|p| p.file_name().map(|s| s.to_os_string()) == file_name); if hit { let _ = tx.send_blocking(()); } diff --git a/src/dock.rs b/src/dock.rs index 1e74b85..e8aa25d 100644 --- a/src/dock.rs +++ b/src/dock.rs @@ -415,6 +415,33 @@ pub fn recompute_dodge(ctx: &Rc) { update_autohide(ctx); } +/// Färska fönster för en ikon — slås upp vid klick-/tooltip-tillfället så +/// att closures aldrig agerar på inaktuellt state. +pub fn fresh_windows(ctx: &Ctx, connector: &str, key: &str) -> Vec { + let service = ctx.service.borrow(); + let Some(service) = service.as_ref() else { + return Vec::new(); + }; + let only_here = ctx.cfg.borrow().only_this_output(connector); + service + .snapshot() + .into_iter() + .filter(|t| { + (!only_here || t.outputs.iter().any(|o| o == connector)) + && icons::lookup(&t.app_id).key == key + }) + .collect() +} + +/// Bygg en färsk kopia av en ikon vid interaktionstillfället. +fn live_item(ctx: &Ctx, connector: &str, item: &Item) -> Rc { + Rc::new(Item { + meta: item.meta.clone(), + windows: fresh_windows(ctx, connector, &item.meta.key), + pinned: item.pinned, + }) +} + /// Gruppera snapshot → ikonrad för en viss skärm. fn build_items(ctx: &Ctx, connector: &str, snapshot: &[ToplevelInfo]) -> Vec { let cfg = ctx.cfg.borrow(); @@ -459,7 +486,7 @@ pub fn refresh_items(ctx: &Rc) { } add_special_buttons(ctx, dock); for item in build_items(ctx, &dock.connector, &snapshot) { - let widget = build_item_widget(ctx, Rc::new(item)); + let widget = build_item_widget(ctx, &dock.connector, Rc::new(item)); dock.panel.append(&widget); } } @@ -496,7 +523,7 @@ fn add_special_buttons(ctx: &Rc, dock: &Rc) { } } -fn build_item_widget(ctx: &Rc, item: Rc) -> gtk::Button { +fn build_item_widget(ctx: &Rc, connector: &str, item: Rc) -> gtk::Button { let cfg = ctx.cfg.borrow(); let icon_size = cfg.appearance.icon_size; let max_dots = cfg.appearance.indicator.max as usize; @@ -543,15 +570,23 @@ fn build_item_widget(ctx: &Rc, item: Rc) -> gtk::Button { btn.set_child(Some(&vbox)); if tooltips { - let tip = match item.windows.len() { - 0 => item.meta.display_name.clone(), - 1 => { - let t = &item.windows[0].title; - if t.is_empty() { item.meta.display_name.clone() } else { t.clone() } - } - n => format!("{} — {n} fönster", item.meta.display_name), - }; - btn.set_tooltip_text(Some(&tip)); + btn.set_has_tooltip(true); + let ctx2 = ctx.clone(); + let item2 = item.clone(); + let conn2 = connector.to_string(); + btn.connect_query_tooltip(move |_, _, _, _, tooltip| { + let windows = fresh_windows(&ctx2, &conn2, &item2.meta.key); + let tip = match windows.len() { + 0 => item2.meta.display_name.clone(), + 1 => { + let t = &windows[0].title; + if t.is_empty() { item2.meta.display_name.clone() } else { t.clone() } + } + n => format!("{} — {n} fönster", item2.meta.display_name), + }; + tooltip.set_text(Some(&tip)); + true + }); } // klick: vänster/mitt/höger @@ -561,12 +596,14 @@ fn build_item_widget(ctx: &Rc, item: Rc) -> gtk::Button { let ctx = ctx.clone(); let item = item.clone(); let btn2 = btn.clone(); + let conn2 = connector.to_string(); click.connect_released(move |gesture, _, _, _| { gesture.set_state(gtk::EventSequenceState::Claimed); + let live = live_item(&ctx, &conn2, &item); match gesture.current_button() { - 1 => primary_action(&ctx, &item, &btn2), - 2 => middle_action(&ctx, &item), - 3 => menu::popup_item_menu(&ctx, &item, &btn2), + 1 => primary_action(&ctx, &live, &btn2), + 2 => middle_action(&ctx, &live), + 3 => menu::popup_item_menu(&ctx, &live, &btn2), _ => {} } }); @@ -578,9 +615,11 @@ fn build_item_widget(ctx: &Rc, item: Rc) -> gtk::Button { { let ctx = ctx.clone(); let item = item.clone(); + let conn2 = connector.to_string(); scroll.connect_scroll(move |_, _, dy| { if ctx.cfg.borrow().behavior.scroll == ScrollAction::CycleWindows { - cycle_windows(&ctx, &item, dy > 0.0); + let live = live_item(&ctx, &conn2, &item); + cycle_windows(&ctx, &live, dy > 0.0); glib::Propagation::Stop } else { glib::Propagation::Proceed diff --git a/src/hotspots.rs b/src/hotspots.rs index 8bba274..db15fe2 100644 --- a/src/hotspots.rs +++ b/src/hotspots.rs @@ -101,8 +101,24 @@ fn build_hotspot( win } +thread_local! { + static LAST_SIGNATURE: std::cell::RefCell = + const { std::cell::RefCell::new(String::new()) }; +} + /// Bygg om alla hotspot-ytor enligt configen (körs vid start + configbyte). +/// Idempotent: oförändrad hotspot-config + skärmuppsättning → ingen ombyggnad. pub fn rebuild(ctx: &Rc) { + let n_monitors = gdk::Display::default() + .map(|d| d.monitors().n_items()) + .unwrap_or(0); + let signature = format!("{:?}|{n_monitors}", ctx.cfg.borrow().hotspots); + let unchanged = LAST_SIGNATURE.with(|s| *s.borrow() == signature); + if unchanged { + return; + } + LAST_SIGNATURE.with(|s| *s.borrow_mut() = signature); + for win in ctx.hotspot_windows.borrow_mut().drain(..) { win.destroy(); } diff --git a/src/settings.rs b/src/settings.rs index 3bb7b2c..50aae62 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -77,7 +77,7 @@ fn row(list: >k::Box, label: &str, widget: &impl IsA) { fn heading(list: >k::Box, text: &str) { let lbl = gtk::Label::new(None); - lbl.set_markup(&format!("{text}")); + lbl.set_markup(&format!("{}", glib::markup_escape_text(text))); lbl.set_halign(gtk::Align::Start); lbl.set_margin_top(10); list.append(&lbl); diff --git a/src/toplevel.rs b/src/toplevel.rs index 5cdc35a..b32d229 100644 --- a/src/toplevel.rs +++ b/src/toplevel.rs @@ -43,6 +43,7 @@ struct ToplevelEntry { activated: bool, outputs: Vec, initialized: bool, // första done har kommit + changed: bool, // något UI-relevant ändrat sedan senaste done } struct OutputEntry { @@ -169,6 +170,7 @@ impl Dispatch for WlState { activated: false, outputs: Vec::new(), initialized: false, + changed: false, }, )); } @@ -199,9 +201,12 @@ impl Dispatch for WlState { Event::AppId { app_id } => { if let Some(e) = state.entry_mut(&id) { e.app_id = app_id; + e.changed = true; } } Event::Title { title } => { + // titlar byts hela tiden (webbläsare, terminaler) — spara + // dem men bygg inte om UI:t; tooltips slås upp färskt ändå if let Some(e) = state.entry_mut(&id) { e.title = title; } @@ -209,11 +214,13 @@ impl Dispatch for WlState { Event::OutputEnter { output } => { if let Some(e) = state.entry_mut(&id) { e.outputs.push(output.id()); + e.changed = true; } } Event::OutputLeave { output } => { if let Some(e) = state.entry_mut(&id) { e.outputs.retain(|o| *o != output.id()); + e.changed = true; } } Event::State { state: raw } => { @@ -231,16 +238,26 @@ impl Dispatch for WlState { } } if let Some(e) = state.entry_mut(&id) { + if e.minimized != min || e.maximized != max || e.activated != act { + e.changed = true; + } e.minimized = min; e.maximized = max; e.activated = act; } } Event::Done => { + let mut mark_dirty = false; if let Some(e) = state.entry_mut(&id) { + if !e.initialized || e.changed { + mark_dirty = true; + } e.initialized = true; + e.changed = false; + } + if mark_dirty { + state.dirty = true; } - state.dirty = true; } Event::Closed => { state.toplevels.retain(|(tid, _)| *tid != id);