diff --git a/.github/AGENTS.md b/.github/AGENTS.md index a561289d..ceaeed1f 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -6,16 +6,27 @@ ## Architecture - Workflows should mirror local CI and release/package validation. - Release automation depends on version checks, packaging manifests, Nix/package checks, artifact upload, and repository publishing behavior. +- `ISSUE_TEMPLATE/` uses GitHub issue forms (YAML), not Markdown templates, so + reporters get fields instead of a wall of text to edit. ## Invariants - Keep CI aligned with `./tools/lint-and-test.sh`. +- Issue-form field `id`s are a public API: `https://wayscriber.com/report` + prefills them by query parameter, and the About dialog in every shipped + binary points at that page. Renaming or removing an id silently drops the + prefill for binaries that can never be updated retroactively. - Keep Linux system dependencies aligned with real build needs for Wayland, Cairo, Pango, GTK4/libadwaita, D-Bus, packaging, and Nix checks. - Do not weaken release checks or skip package layout/version validation without a documented reason. ## Coupled Changes - Workflow dependency changes may require updates to `tools/`, `packaging/`, `flake.nix`, and setup docs. - Release workflow changes may require updates to version scripts and packaging manifests. +- Issue-form field ids are coupled with `wayscriber-website/report/index.html` + and with `REPORT_URL` in `src/about_window/content.rs`. ## Validation - Prefer local script validation before changing CI. - For workflow-only edits, run `git diff --check` and inspect YAML carefully. +- After editing an issue form, confirm the prefill contract still holds: + `issues/new?template=bug_report.yml&diagnostics=hello` must show `hello` in + the Diagnostics box. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index c3aae050..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: "[BUG]" -labels: '' -assignees: devmobasa - ---- - -## Summary -Explain what went wrong and what you were doing when it happened. Include any on-screen error message. - -## Steps to Reproduce -List the exact sequence of actions that leads to the issue. If you are unsure about an intermediate step, please so note. - -1. Launch Wayscriber - - If you normally run it once: open a terminal and run - ``` - RUST_LOG=wayscriber=debug wayscriber --active - ``` - - If you normally run the daemon: open a terminal and run - ``` - pkill wayscriber - RUST_LOG=wayscriber=debug wayscriber --daemon - ``` - Then trigger your usual hotkey. -2. Describe the actions you perform inside Wayscriber (key presses, mouse actions, menu selections, etc.). -3. Mention any additional system commands or applications involved. -4. Describe the problem you observe (e.g. capture fails, overlay freezes, unexpected output). - -## Expected Result -Tell us what you expected to happen instead. - -## Actual Result / Logs -Include the full terminal output from the commands above. Copy everything beginning with the command itself down to the last line printed. -If you start Wayscriber through systemd, also run the command below and attach the relevant lines: -``` -journalctl --user -u wayscriber.service -b -``` - -## Configuration Details -- Wayscriber version (`wayscriber --version`, or the package name and version you installed) -- Whether you run in daemon mode or one-shot mode -- Relevant parts of your configuration file (usually `~/.config/wayscriber/config.toml`); remove or mask any private data - -## Environment -- Linux distribution and version (e.g. `Arch Linux 2025.02.01`, `Fedora 41`) -- Wayland compositor/window manager (e.g. `Hyprland 0.44`, `Sway 1.9`) -- Installed portal backend (run `pacman -Qs xdg-desktop-portal` or `apt list xdg-desktop-portal*` and list the packages) -- Capture tools status (run each of the following and report their versions or any errors): - ``` - grim --version - slurp --version - wl-copy --version - ``` -- GPU model and graphics driver (if the issue appears rendering-related) - -## Repro Frequency / Workarounds -Does it happen every time or only sometimes? Mention any workaround you found that temporarily avoids the bug. - -## Additional Context -Attach screenshots, screen recordings, crash dumps, or links to related issues/PRs that might help us understand the bug. - -## System Logs (optional) -If you see messages that look related in other logs (for example, compositor logs or `dmesg`), include them here. Mention the command you ran to obtain the logs. - ---- - -Thank you for helping us improve Wayscriber! Detailed reports make a big difference. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..9b2d9e10 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,82 @@ +# The field `id`s below are a public API. +# +# https://wayscriber.com/report links here with query parameters that prefill +# them (`?template=bug_report.yml&diagnostics=…`). Renaming or removing an id +# silently drops the prefill for every already-shipped binary, so treat an id +# change as a coupled change with `wayscriber-website/report/index.html`. +name: Bug report +description: Something broke or behaved wrong +title: "[BUG] " +labels: [bug] +assignees: [devmobasa] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time. You do not need to fill in every box — + what happened, and roughly how to see it happen, is already useful. + + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What were you doing, and what went wrong? + placeholder: I pressed my overlay hotkey and the screen stayed black. + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. … + 2. … + 3. … + + - type: textarea + id: diagnostics + attributes: + label: Diagnostics + description: > + In Wayscriber, open About → "Report a problem" (or "Copy diagnostics") + and paste here. It contains only your version, install source, desktop, + and enabled features. + render: text + + - type: dropdown + id: compositor + attributes: + label: Compositor / desktop + options: + - Hyprland + - Sway + - GNOME + - KDE Plasma + - Niri + - River + - Wayfire + - COSMIC + - Other / not sure + + - type: dropdown + id: install-method + attributes: + label: How did you install Wayscriber? + options: + - APT (deb) + - RPM + - AUR + - Nix + - Tarball + - Built from source + - Not sure + + - type: textarea + id: logs + attributes: + label: Logs (optional) + description: > + If you can, follow https://wayscriber.com/docs/troubleshooting/logs.html + and paste the output. + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..8bbedd56 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Not sure it's a bug? Ask first + url: https://github.com/devmobasa/wayscriber/discussions + about: Questions and "is this expected?" go here. + - name: Report without a GitHub account + url: https://wayscriber.com/report + about: The website lists every way to reach the project. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..355d53bd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,49 @@ +# See the note at the top of `bug_report.yml`: field ids here are not prefilled +# by the website today, but the same rule applies if that ever changes. +name: Feature request +description: Ask for something Wayscriber does not do yet +title: "[FEATURE] " +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + Requests are welcome when they are clear. If a request does not fit the + project or the timing, the answer may still be no — that is normal and + not personal. See + [How to Ask for Features](https://wayscriber.com/docs/ethos/asking.html). + + - type: textarea + id: problem + attributes: + label: What problem are you trying to solve? + description: The problem, not the solution you have in mind. + validations: + required: true + + - type: textarea + id: workflow + attributes: + label: The workflow you want + description: Walk through it step by step, as you would do it. + + - type: textarea + id: alternatives + attributes: + label: Why do the existing features not fit? + description: What did you try, and where did it fall short? + + - type: input + id: environment + attributes: + label: Compositor and distro + placeholder: Hyprland 0.44 on Arch Linux + + - type: checkboxes + id: help + attributes: + label: Can you help? + options: + - label: I can test a build + - label: I can share screenshots or a short recording + - label: I could contribute a PR diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e71f5dc..dda3d83a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- About dialog: a "Report a problem" row. It copies your diagnostics to the clipboard and opens `https://wayscriber.com/report`, which hands them straight into a prefilled bug form. Nothing is sent automatically, and the diagnostics travel in the URL fragment, so they never reach a server log. + ### Breaking (Rust source) - The unified top toolbar is now the only layout. Panel-era typed config fields and order groups are removed from the serde model (`side_*` placement/pin/pane keys, `show_settings_section` / mode overrides, and `ui.toolbar.items.order.{side_sections,actions,pages,boards,presets,tool_options,sessions}`). Authored values at those exact paths remain in `config.toml` as retired settings and no longer affect the overlay. Matching keys under `runtime-ui.toml`'s recognized `item_order` map are pruned on rewrite. diff --git a/src/about_window/AGENTS.md b/src/about_window/AGENTS.md index de10f8f6..e9b06603 100644 --- a/src/about_window/AGENTS.md +++ b/src/about_window/AGENTS.md @@ -23,6 +23,10 @@ - Chrome colors come from `crate::ui::theme`, never hardcoded literals. - Every outbound link points at wayscriber.com (no code-host links); the update card never installs anything. +- Nothing leaves the machine without the user asking. "Report a problem" copies + the diagnostics and opens a page; it never submits anything. +- Diagnostics ride in the URL *fragment* (`#d=`), never a query string, + so they cannot reach wayscriber.com access logs. - The window is sized from `layout::plan`, so adding a row must not need a constant edit elsewhere. - Blocking work (the update fetch) runs in the event loop, never in a handler. @@ -31,6 +35,11 @@ - About dialog changes may affect app metadata, clipboard behavior, docs, and Wayland handler dependencies. - Update-status wording is shared with `src/update_check/`; the dialog only reads its cache. +- `REPORT_URL` is a contract with `wayscriber-website/report/index.html`, which + decodes the `#d=` fragment and forwards it into the GitHub issue form's + `diagnostics` field (`.github/ISSUE_TEMPLATE/bug_report.yml`). Released + binaries cannot be told about a new address, so the page moves the channel, + never the constant. ## Validation - Add focused tests if logic becomes testable (`content`, `layout`, diff --git a/src/about_window/content.rs b/src/about_window/content.rs index 6b2f854f..25b8ca91 100644 --- a/src/about_window/content.rs +++ b/src/about_window/content.rs @@ -13,12 +13,23 @@ use crate::update_check::{ /// need to send people to a code host to find them. const WEBSITE_URL: &str = "https://wayscriber.com"; const DOCS_URL: &str = "https://wayscriber.com/docs/"; +/// The one URL the report path knows. The page behind it decides where reports +/// actually go, so channels can be added or moved without another release — +/// binaries already in the wild cannot be told about a new address. +pub(super) const REPORT_URL: &str = "https://wayscriber.com/report"; /// What activating a focusable element does. #[derive(Debug, Clone, PartialEq, Eq)] pub(super) enum AboutAction { OpenUrl(String), CopyText(String), + /// Copy the diagnostics *and* open the report page, which also carries + /// them in its URL fragment: the clipboard is the fallback for whenever + /// the page or the form does not pick the fragment up. + ReportBug { + url: String, + diagnostics: String, + }, CheckForUpdates, Close, } @@ -35,7 +46,34 @@ pub(super) struct ButtonSpec { pub(super) struct LinkRow { pub(super) title: &'static str, pub(super) detail: String, - pub(super) url: String, + /// Rows carry their own action so adding one stays a single edit here; + /// `interaction.rs` only maps elements to it. + pub(super) action: AboutAction, +} + +impl LinkRow { + fn opening(title: &'static str, detail: &str, url: String) -> Self { + Self { + title, + detail: detail.to_string(), + action: AboutAction::OpenUrl(url), + } + } + + /// The report row, built from one snapshot of the diagnostics: the text + /// that reaches the clipboard and the text encoded into the URL fragment + /// are the same `String`, so they cannot drift apart if `report()` ever + /// stops being deterministic. + fn reporting(diagnostics: String) -> Self { + Self { + title: "Report a problem", + detail: "Opens wayscriber.com · your app details come along".to_string(), + action: AboutAction::ReportBug { + url: super::diagnostics::report_url(REPORT_URL, &diagnostics), + diagnostics, + }, + } + } } /// Live state of the update row. @@ -194,26 +232,23 @@ impl AboutContent { tagline: "Screen annotation for Wayland", version_line: format!("Version {version}"), links: vec![ - LinkRow { - title: "Website", - detail: "wayscriber.com".to_string(), - url: WEBSITE_URL.to_string(), - }, - LinkRow { - title: "Documentation", - detail: "Setup, config, troubleshooting".to_string(), - url: DOCS_URL.to_string(), - }, - LinkRow { - title: "Release notes", - detail: "What changed in each version".to_string(), - url: DEFAULT_NOTES_URL.to_string(), - }, - LinkRow { - title: "How to update", - detail: "Steps for your install method".to_string(), - url: update_instructions_url(), - }, + LinkRow::opening("Website", "wayscriber.com", WEBSITE_URL.to_string()), + LinkRow::opening( + "Documentation", + "Setup, config, troubleshooting", + DOCS_URL.to_string(), + ), + LinkRow::opening( + "Release notes", + "What changed in each version", + DEFAULT_NOTES_URL.to_string(), + ), + LinkRow::opening( + "How to update", + "Steps for your install method", + update_instructions_url(), + ), + LinkRow::reporting(super::diagnostics::report()), ], meta_lines, commit, @@ -281,17 +316,56 @@ mod tests { AboutContent::from_parts("0.9.22", "51113dd1", Some("2026-07-20"), Some("apt")) } + /// The URL a row navigates to, whatever kind of action it carries. + fn target_url(link: &LinkRow) -> &str { + match &link.action { + AboutAction::OpenUrl(url) => url, + AboutAction::ReportBug { url, .. } => url, + other => panic!("link row {:?} does not navigate: {other:?}", link.title), + } + } + #[test] fn links_stay_on_wayscriber_com() { - for link in content().links { + for link in &content().links { + let url = target_url(link); assert!( - link.url.starts_with("https://wayscriber.com"), - "unexpected link target: {}", - link.url + url.starts_with("https://wayscriber.com"), + "unexpected link target: {url}" ); } } + /// The report row is the whole point of the indirection: it must exist, it + /// must go to the stable `/report` page, and the text it copies must be + /// byte-for-byte the text the page decodes out of the fragment. Asserting + /// against the fragment rather than a second `report()` call keeps this + /// honest even if the diagnostics ever stop being deterministic. + #[test] + fn the_report_row_copies_exactly_what_it_sends() { + let link = content() + .links + .into_iter() + .find(|link| link.title == "Report a problem") + .expect("About offers a way to report a problem"); + + let AboutAction::ReportBug { url, diagnostics } = link.action else { + panic!("the report row must copy as well as open"); + }; + + let fragment = url + .strip_prefix(&format!("{REPORT_URL}#d=")) + .expect("the report row opens the stable page with a diagnostics fragment"); + let decoded = crate::base64::decode_standard(fragment).expect("fragment is valid base64"); + + assert_eq!( + String::from_utf8(decoded).unwrap(), + diagnostics, + "the clipboard copy and the URL fragment must be one snapshot" + ); + assert!(!diagnostics.is_empty(), "the row must carry diagnostics"); + } + #[test] fn build_metadata_collapses_gracefully() { let full = content(); diff --git a/src/about_window/diagnostics.rs b/src/about_window/diagnostics.rs index d004e894..a6edd6b6 100644 --- a/src/about_window/diagnostics.rs +++ b/src/about_window/diagnostics.rs @@ -48,6 +48,24 @@ pub(super) fn report() -> String { format_report(&DiagnosticsEnv::capture(), compiled_features()) } +/// `#d=`, for handing the diagnostics to the +/// report page without the user pasting anything. +/// +/// The caller passes the text rather than this re-deriving it, so the copy that +/// reaches the clipboard and the copy in the fragment are one snapshot. +/// +/// A fragment, not a query string: browsers never send it to the server, so +/// the diagnostics cannot land in wayscriber.com access logs. Base64 keeps the +/// newlines intact; `+`, `/`, and `=` are all legal fragment characters and +/// `atob()` on the page decodes the payload as-is. +pub(super) fn report_url(base: &str, diagnostics: &str) -> String { + format!("{base}#d={}", encode_fragment(diagnostics)) +} + +fn encode_fragment(report: &str) -> String { + crate::base64::encode_standard(report.as_bytes()) +} + /// Cargo features this binary was built with, which decide whether the tray, /// portal capture, GTK toolbars, and tablet input exist at all. fn compiled_features() -> Vec<&'static str> { @@ -181,4 +199,22 @@ mod tests { fn captured_report_is_never_empty() { assert!(report().starts_with("Wayscriber ")); } + + /// The website page decodes the fragment with `atob`, so it has to be + /// exactly the diagnostics text and nothing else — newlines and non-ASCII + /// included, which is what the page's `TextDecoder` step exists for. + #[test] + fn the_report_url_fragment_decodes_back_to_the_diagnostics() { + let diagnostics = "Wayscriber 1.0.0\nDesktop: Ünïcödé · Hyprland\nFeatures: none"; + + let url = report_url("https://wayscriber.com/report", diagnostics); + + let (base, fragment) = url + .split_once("#d=") + .expect("report URL carries a #d= payload"); + assert_eq!(base, "https://wayscriber.com/report"); + + let decoded = crate::base64::decode_standard(fragment).expect("fragment is valid base64"); + assert_eq!(String::from_utf8(decoded).unwrap(), diagnostics); + } } diff --git a/src/about_window/interaction.rs b/src/about_window/interaction.rs index 6ebae977..3ea69b9f 100644 --- a/src/about_window/interaction.rs +++ b/src/about_window/interaction.rs @@ -47,10 +47,7 @@ pub(super) fn action_for( ) -> Option { match element { Element::UpdateCard => update.action(), - Element::Link(index) => content - .links - .get(index) - .map(|link| AboutAction::OpenUrl(link.url.clone())), + Element::Link(index) => content.links.get(index).map(|link| link.action.clone()), Element::Button(index) => content .buttons() .get(index) @@ -181,7 +178,7 @@ mod tests { ); assert_eq!( action_for(Element::Link(0), &content, &update), - Some(AboutAction::OpenUrl(content.links[0].url.clone())) + Some(content.links[0].action.clone()) ); assert_eq!( action_for(Element::Close, &content, &update), @@ -190,6 +187,24 @@ mod tests { assert_eq!(action_for(Element::Link(99), &content, &update), None); } + /// A row's action comes from the row itself, so a row that does more than + /// open a URL keeps working through the same one element list. + #[test] + fn a_row_can_carry_an_action_that_is_not_a_plain_link() { + let content = AboutContent::build(); + let index = content + .links + .iter() + .position(|link| matches!(link.action, AboutAction::ReportBug { .. })) + .expect("one row reports a problem"); + + assert_eq!( + action_for(Element::Link(index), &content, &available()), + Some(content.links[index].action.clone()) + ); + assert!(focus_order(&content, &available()).contains(&Element::Link(index))); + } + #[test] fn focus_wraps_in_both_directions() { assert_eq!(step_focus(None, 3, 1), Some(0)); diff --git a/src/about_window/render/draw.rs b/src/about_window/render/draw.rs index 20cdc5d3..1d0ff17c 100644 --- a/src/about_window/render/draw.rs +++ b/src/about_window/render/draw.rs @@ -254,10 +254,9 @@ fn link_rows(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { theme.accent_bright, ); - let chevron_x = rect.0 + rect.2 - PADDING - CHEVRON_SIZE / 2.0; draw_chevron( ctx, - chevron_x, + chevron_x(*rect), rect.1 + rect.3 / 2.0, CHEVRON_SIZE, if state.is_highlighted() { @@ -267,8 +266,7 @@ fn link_rows(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { }, ); - let text_left = rect.0 + PADDING; - let max_width = (chevron_x - 8.0 - text_left).max(0.0); + let (text_left, max_width) = row_text_bounds(*rect); label( ctx, title_style, @@ -290,6 +288,18 @@ fn link_rows(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { } } +fn chevron_x(rect: Rect) -> f64 { + rect.0 + rect.2 - PADDING - CHEVRON_SIZE / 2.0 +} + +/// Where a link row's text starts and how wide it may be before it runs into +/// the chevron. Shared with the test that keeps row wording inside the dialog's +/// fixed width. +fn row_text_bounds(rect: Rect) -> (f64, f64) { + let text_left = rect.0 + PADDING; + (text_left, (chevron_x(rect) - 8.0 - text_left).max(0.0)) +} + fn meta_lines(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { let meta_style = style(META_SIZE, cairo::FontWeight::Normal); let max_width = frame.plan.width - frame.plan.icon.0 * 2.0; @@ -427,6 +437,34 @@ mod tests { cairo::Context::new(&surface).unwrap() } + /// The dialog is a fixed width, so row wording has to be chosen to fit it. + /// An ellipsis here means a row's text was written without checking. + #[test] + fn link_row_wording_fits_without_being_ellipsized() { + let content = AboutContent::build(); + let plan = layout::plan(&content); + let ctx = context(&plan); + + let title_style = style(ROW_TITLE_SIZE, cairo::FontWeight::Normal); + let detail_style = style(DETAIL_SIZE, cairo::FontWeight::Normal); + + for (rect, link) in plan.link_rows.iter().zip(content.links.iter()) { + let (_, max_width) = row_text_bounds(*rect); + + assert_eq!( + fit(&ctx, link.title, title_style, max_width), + link.title, + "row title does not fit" + ); + assert_eq!( + fit(&ctx, &link.detail, detail_style, max_width), + link.detail, + "detail of the {:?} row does not fit", + link.title + ); + } + } + #[test] fn every_update_state_paints_cleanly() { let content = AboutContent::build(); diff --git a/src/about_window/state.rs b/src/about_window/state.rs index 1b232050..c004348f 100644 --- a/src/about_window/state.rs +++ b/src/about_window/state.rs @@ -12,6 +12,7 @@ use super::{AboutWindowState, clipboard, icon, surface_size}; /// How the footer acknowledges an action that has no visible result of its own. const COPIED_NOTICE: &str = "Copied to clipboard"; const OPENED_NOTICE: &str = "Opened in your browser"; +const REPORTED_NOTICE: &str = "Diagnostics copied — paste them if the form asks"; impl AboutWindowState { #[allow(clippy::too_many_arguments)] @@ -131,6 +132,14 @@ impl AboutWindowState { clipboard::copy_text_to_clipboard(&text); self.set_notice(COPIED_NOTICE); } + // Copy as well as open: the URL carries the same diagnostics in its + // fragment, but a browser that never launches, or a form that drops + // the prefill, still leaves them one paste away. + AboutAction::ReportBug { url, diagnostics } => { + clipboard::copy_text_to_clipboard(&diagnostics); + clipboard::open_url(&url); + self.set_notice(REPORTED_NOTICE); + } AboutAction::CheckForUpdates => self.begin_update_check(), AboutAction::Close => self.should_exit = true, }