From cfcf7a7669ca43dcfea1abf5a378fad8c55f6dce Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:23:41 +0200 Subject: [PATCH 1/3] chore: new changelog generator (#855) * chore: new changelog generator * ci: require a change file on PRs --- .changeset/github_release_changelog.md | 5 + .github/workflows/changelog_publish.yml | 35 ++++++ .github/workflows/changeset_check.yml | 23 ++++ .github/workflows/oneclient_release.yml | 103 ++++++------------ CONTRIBUTING.md | 9 ++ README.md | 5 + knope.toml | 28 +++++ packages/oneclient_app/src/hooks/mod.rs | 2 +- .../src/hooks/queries/changelog.rs | 22 ++-- .../oneclient_app/src/hooks/queries/mod.rs | 2 +- .../src/view/app/settings/changelog.rs | 84 ++++++-------- packages/oneclient_core/src/changelog.rs | 56 ++-------- packages/oneclient_core/src/lib.rs | 2 +- 13 files changed, 193 insertions(+), 183 deletions(-) create mode 100644 .changeset/github_release_changelog.md create mode 100644 .github/workflows/changelog_publish.yml create mode 100644 .github/workflows/changeset_check.yml create mode 100644 knope.toml diff --git a/.changeset/github_release_changelog.md b/.changeset/github_release_changelog.md new file mode 100644 index 00000000..eece6299 --- /dev/null +++ b/.changeset/github_release_changelog.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +# The changelog page now shows each GitHub release's notes diff --git a/.github/workflows/changelog_publish.yml b/.github/workflows/changelog_publish.yml new file mode 100644 index 00000000..97c403d3 --- /dev/null +++ b/.github/workflows/changelog_publish.yml @@ -0,0 +1,35 @@ +name: Publish Changelog + +on: + release: + types: [published, edited] + workflow_dispatch: + +concurrency: + group: changelog-publish + cancel-in-progress: true + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: write + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + steps: + # The launcher reads releases/latest/download/changelog.json, so the + # snapshot of the last 50 release bodies always lives on the latest release. + - name: Build changelog.json from release bodies + run: | + gh api "repos/$GH_REPO/releases?per_page=50" --jq '[.[] + | select(.draft | not) + | select(.tag_name | startswith("oneclient-")) + | { + version: (.tag_name | ltrimstr("oneclient-")), + body: ((.body // "") | sub("^Automated release for OneClient [^\n]*\\s*"; "")) + }]' > changelog.json + jq -r '.[].version' changelog.json + + - name: Upload to latest release + run: gh release upload "$(gh release view --json tagName -q .tagName)" changelog.json --clobber diff --git a/.github/workflows/changeset_check.yml b/.github/workflows/changeset_check.yml new file mode 100644 index 00000000..690cc4e4 --- /dev/null +++ b/.github/workflows/changeset_check.yml @@ -0,0 +1,23 @@ +name: Changeset Check + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + +jobs: + changeset: + if: ${{ !contains(github.event.pull_request.labels.*.name, 'no changeset') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + + - name: Require a change file + env: + BASE_REF: ${{ github.base_ref }} + run: | + if ! git diff --name-only --diff-filter=A "origin/$BASE_REF...HEAD" -- '.changeset/*.md' | grep -q .; then + echo "::error::Add a change file with \`knope document-change\`, or label the PR 'no changeset' if it isn't user-facing." + exit 1 + fi diff --git a/.github/workflows/oneclient_release.yml b/.github/workflows/oneclient_release.yml index 2856a7f4..106f1c40 100644 --- a/.github/workflows/oneclient_release.yml +++ b/.github/workflows/oneclient_release.yml @@ -3,17 +3,6 @@ name: OneClient Release Build on: workflow_dispatch: - inputs: - bump: - description: 'Bump the workspace version in Cargo.toml before releasing' - type: choice - required: false - default: none - options: - - none - - patch - - minor - - major # NOTE: CARGO_PACKAGER_SIGN_PRIVATE_KEY* are deliberately NOT declared at # workflow level. Swatinem/rust-cache folds every `CARGO*`-prefixed environment @@ -37,18 +26,25 @@ jobs: sha: ${{ steps.version.outputs.sha }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + # Knope finds the commit (and so the PR author) that added each change file + fetch-depth: 0 - name: Setup Rust - if: inputs.bump != 'none' uses: ./.github/actions/setup-rust with: restore-cache: false - - name: Resolve version, bump if requested, refuse to clobber a release + - name: Install Knope + uses: ./.github/actions/install-cargo-tool + with: + tool: knope + version: 0.23.0 + + - name: Consume changesets id: version env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BUMP: ${{ inputs.bump }} shell: bash run: | set -euo pipefail @@ -57,63 +53,29 @@ jobs: sed -n -E '/^\[workspace\.package\]/,/^\[/ s/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' Cargo.toml | head -n1 } + # No change files means a re-run of a release whose version commit + # already landed, so carry on with the current version. + RELEASED=0 + if compgen -G ".changeset/*.md" > /dev/null; then + knope prepare-release + cargo update --workspace --offline || cargo update --workspace + RELEASED=1 + fi + VERSION="$(read_version)" if [ -z "$VERSION" ]; then echo "::error::Could not read version from [workspace.package] in Cargo.toml" exit 1 fi - echo "Current version: $VERSION" - - if [ "$BUMP" != "none" ]; then - if ! [[ "$VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then - echo "::error::Version '$VERSION' is not plain semver major.minor.patch; cannot bump automatically." - exit 1 - fi - MAJOR="${BASH_REMATCH[1]}"; MINOR="${BASH_REMATCH[2]}"; PATCH="${BASH_REMATCH[3]}" - case "$BUMP" in - major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; - minor) MINOR=$((MINOR + 1)); PATCH=0 ;; - patch) PATCH=$((PATCH + 1)) ;; - esac - NEW_VERSION="$MAJOR.$MINOR.$PATCH" - echo "Bumping ($BUMP): $VERSION -> $NEW_VERSION" - - # Only rewrite the `version` key inside [workspace.package]; the - # dependency pins further down the file must not be touched. - NEW_VERSION="$NEW_VERSION" python3 - <<'PY' - import os, re - new = os.environ["NEW_VERSION"] - src = open("Cargo.toml").read() - start = src.index("[workspace.package]") - end = src.find("\n[", start + 1) - end = len(src) if end == -1 else end - section, count = re.subn( - r'(?m)^version\s*=\s*"[^"]+"', f'version = "{new}"', src[start:end], count=1 - ) - assert count == 1, "no version key found in [workspace.package]" - open("Cargo.toml", "w").write(src[:start] + section + src[end:]) - PY - - # Workspace members inherit `version.workspace = true`, so only the - # lockfile's own entries for those members need refreshing. - cargo update --workspace --offline || cargo update --workspace - - ACTUAL="$(read_version)" - if [ "$ACTUAL" != "$NEW_VERSION" ]; then - echo "::error::Version rewrite failed (Cargo.toml still reports '$ACTUAL')" - exit 1 - fi - VERSION="$NEW_VERSION" - fi - TAG="oneclient-$VERSION" + echo "Releasing OneClient $VERSION" # Abort rather than clobber: a published release (or an existing tag, # which only appears once a release is published) means this version # already shipped. A *draft* is a previous failed/partial run of this # same workflow, so reuse it. if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then - echo "::error::Tag $TAG already exists on origin - OneClient $VERSION has already been released. Re-run with a bump, or bump the version in Cargo.toml." + echo "::error::Tag $TAG already exists on origin - OneClient $VERSION has already been released. Add a change file with \`knope document-change\` first." exit 1 fi @@ -123,17 +85,17 @@ jobs: RELEASE_EXISTS=1 fi if [ "$RELEASE_EXISTS" = "1" ] && [ "$IS_DRAFT" != "true" ]; then - echo "::error::Release $TAG already exists and is published - refusing to overwrite OneClient $VERSION. Re-run with a bump, or bump the version in Cargo.toml." + echo "::error::Release $TAG already exists and is published - refusing to overwrite OneClient $VERSION. Add a change file with \`knope document-change\` first." exit 1 fi - # Commit the bump only after the guards pass, so a rejected release - # does not leave a stray version bump on the branch. - if [ "$BUMP" != "none" ]; then + # Commit only after the guards pass, so a rejected release does not + # leave a stray version bump on the branch. + if [ "$RELEASED" = "1" ]; then git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Cargo.toml Cargo.lock - git commit -m "chore: bump to $VERSION" + git add -A Cargo.toml Cargo.lock .changeset CHANGELOG.md + git commit -m "chore: release $VERSION" git push origin "HEAD:${GITHUB_REF_NAME}" fi @@ -143,11 +105,18 @@ jobs: echo "Reusing existing draft release $TAG" gh release edit "$TAG" --target "$SHA" else + # The body is what the launcher's changelog page shows + awk -v heading="## $VERSION " '/^## /{p = index($0, heading) == 1; next} p' \ + CHANGELOG.md > notes.md 2>/dev/null || true + if ! grep -q '[^[:space:]]' notes.md; then + echo "::error::No changelog entry for $VERSION. Add a change file with \`knope document-change\` first." + exit 1 + fi gh release create "$TAG" \ --draft \ --target "$SHA" \ --title "OneClient $VERSION" \ - --notes "Automated release for OneClient $VERSION." + --notes-file notes.md fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" @@ -189,7 +158,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - # Pin to the commit `prepare` resolved - with `bump` that is the version + # Pin to the commit `prepare` resolved - after a release that is the version # commit it just pushed, which github.sha would not point at. - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e7e670f..a3771b55 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,6 +73,15 @@ and per-OS prerequisites. Once you have finished making your changes, create a pull request (PR) to submit them. - Fill out the template to help reviewers understand your changes and the purpose of your PR. +- If your change is user-facing, add a change file with `knope document-change`, or create `.changeset/.md` by hand: + ```md + --- + default: patch + --- + + # Fixed the thing players noticed + ``` + Use `patch` for fixes, `minor` for features and `major` for breaking changes. The title becomes your line in the release notes. PRs without one fail the `Changeset Check` unless a maintainer labels them `no changeset`. - If you are addressing an existing issue, don't forget to [link your PR to the issue]. - Enable the checkbox to [allow maintainer edits] so that the branch can be updated for merging. - Once you submit your PR, a team member will review your proposal. They may ask questions or request additional information. diff --git a/README.md b/README.md index d0134c83..7f0164f4 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,11 @@ cargo packager --release -p oneclient_app --formats The workspace shares a single version, defined in the root [`Cargo.toml`](./Cargo.toml) under `[workspace.package]`. +Versions and release notes come from [Knope](https://knope.tech) change files in [`.changeset/`](./.changeset). +Add one per user-facing change with `knope document-change` (installed via `cargo install knope`). The +`OneClient Release Build` workflow consumes them, bumps the version, writes [`CHANGELOG.md`](./CHANGELOG.md), +and uses the new entry as the GitHub release body, which is what the launcher's changelog page shows. + ## Code signing diff --git a/knope.toml b/knope.toml new file mode 100644 index 00000000..189d4e1b --- /dev/null +++ b/knope.toml @@ -0,0 +1,28 @@ +[package] +versioned_files = ["Cargo.toml"] +changelog = "CHANGELOG.md" + +[changes] +ignore_conventional_commits = true + +[release_notes] +change_templates = [ + "- $summary by [$pr_author_login](https://github.com/$pr_author_login)", + "- $summary", +] + +[github] +owner = "Polyfrost" +repo = "OneLauncher" + +[[workflows]] +name = "prepare-release" + +[[workflows.steps]] +type = "PrepareRelease" + +[[workflows]] +name = "document-change" + +[[workflows.steps]] +type = "CreateChangeFile" diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index c01c78ee..2464288f 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -41,7 +41,7 @@ pub use queries::{ UseDiscardLeftovers, UseLogAction, UseRefreshAccount, UseRemoveAccount, UseScreenshotAction, UseSetDefaultAccount, UseStorageAction, UseUploadLog, VERSIONS_PAGE_SIZE, accounts_have_microsoft, available_bundles, bundle_overrides_map, bundles_with_status_items, - category_list, changelog_error, changelog_groups, changelog_is_loading, cluster_art_url, + category_list, changelog_entries, changelog_error, changelog_is_loading, cluster_art_url, cluster_content_items, content_type_for_slug, disable_warnings, game_versions, has_migration_data, invalidate_cluster_content_queries, invalidate_cluster_queries, invalidate_java_queries, invalidate_leftovers_queries, invalidate_logs_queries, diff --git a/packages/oneclient_app/src/hooks/queries/changelog.rs b/packages/oneclient_app/src/hooks/queries/changelog.rs index 3f4d0eab..19917853 100644 --- a/packages/oneclient_app/src/hooks/queries/changelog.rs +++ b/packages/oneclient_app/src/hooks/queries/changelog.rs @@ -1,38 +1,30 @@ use freya::query::{Query, QueryCapability, UseQuery, use_query}; -use oneclient_core::{ChangelogGroup, LauncherError, fetch_changelog, parse_changelog}; - -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct ChangelogKeys { - pub meta_url_base: String, -} +use oneclient_core::{ChangelogEntry, LauncherError, fetch_changelog}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct ChangelogQuery; impl QueryCapability for ChangelogQuery { - type Ok = Vec; + type Ok = Vec; type Err = LauncherError; - type Keys = ChangelogKeys; + type Keys = (); async fn run(&self, _keys: &Self::Keys) -> Result { let state = crate::launcher::state()?; - let markdown = fetch_changelog(&state.services.requester).await?; - Ok(parse_changelog(&markdown)) + fetch_changelog(&state.services.requester).await } } pub fn use_changelog() -> UseQuery { - let meta_url_base = super::use_meta_url_key(); - - use_query(Query::new(ChangelogKeys { meta_url_base }, ChangelogQuery)) + use_query(Query::new((), ChangelogQuery)) } -pub fn changelog_groups(query: &UseQuery) -> Option> { +pub fn changelog_entries(query: &UseQuery) -> Option> { super::state::settled_or_loading(query) } pub fn latest_changelog_version(query: &UseQuery) -> Option { - changelog_groups(query).and_then(|groups| groups.first().map(|group| group.version.clone())) + changelog_entries(query).and_then(|entries| entries.first().map(|entry| entry.version.clone())) } pub fn changelog_error(query: &UseQuery) -> Option { diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index d838f542..dbfabdd3 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -56,7 +56,7 @@ pub use bundles::{ use_onboarding_bundles, }; pub use changelog::{ - changelog_error, changelog_groups, changelog_is_loading, latest_changelog_version, + changelog_entries, changelog_error, changelog_is_loading, latest_changelog_version, use_changelog, }; pub use cluster_content::{cluster_content_items, use_cluster_content, use_migratable_routes}; diff --git a/packages/oneclient_app/src/view/app/settings/changelog.rs b/packages/oneclient_app/src/view/app/settings/changelog.rs index 110d71d5..ce21b702 100644 --- a/packages/oneclient_app/src/view/app/settings/changelog.rs +++ b/packages/oneclient_app/src/view/app/settings/changelog.rs @@ -4,9 +4,9 @@ use freya::animation::{ use freya::prelude::*; use super::settings_page; -use crate::components::{Icon, IconType}; +use crate::components::{Icon, IconType, Markdown, MarkdownStyle}; use crate::hooks::{ - changelog_error, changelog_groups, changelog_is_loading, latest_changelog_version, + changelog_entries, changelog_error, changelog_is_loading, latest_changelog_version, use_changelog, use_dispatch, use_settings_snapshot, }; use crate::theme::colors; @@ -55,15 +55,15 @@ impl Component for SettingsChangelog { .into_element(); } - let groups = changelog_groups(&query).unwrap_or_default(); + let entries = changelog_entries(&query).unwrap_or_default(); settings_page() - .children(groups.into_iter().enumerate().map(|(i, group)| { - let current = group.version == installed_version; + .children(entries.into_iter().enumerate().map(|(i, entry)| { + let current = entry.version == installed_version; ReleaseCard { - version: group.version, + version: entry.version, current, - changes: group.changes, + body: entry.body, initially_open: i == 0, } .into_element() @@ -79,7 +79,7 @@ const CHEVRON_CLOSED_DEG: f32 = -90.; struct ReleaseCard { version: String, current: bool, - changes: Vec, + body: String, initially_open: bool, } @@ -110,8 +110,6 @@ impl Component for ReleaseCard { self.version.clone() }; - let changes = self.changes.clone(); - rect() .vertical() .width(Size::fill()) @@ -144,48 +142,30 @@ impl Component for ReleaseCard { ), ) .maybe_child(is_open.then(|| { - rect() - .vertical() - .width(Size::fill()) - .spacing(4.) - .padding(Gaps::new(0., 0., 0., 6.)) - .children(if changes.is_empty() { - vec![ - rect() - .child( - label() - .text("No changes recorded for this version.") - .font_size(12.) - .color(colors::fg_secondary()), - ) - .into_element(), - ] - } else { - changes - .into_iter() - .map(|change| { - rect() - .horizontal() - .width(Size::fill()) - .spacing(8.) - .child( - label() - .text("•") - .font_size(12.) - .color(colors::fg_secondary()), - ) - .child( - label() - .text(change) - .font_size(12.) - .width(Size::flex(1.0)) - .color(colors::fg_primary()), - ) - .into_element() - }) - .collect() - }) - .into_element() + if self.body.trim().is_empty() { + label() + .text("No changes recorded for this version.") + .font_size(12.) + .color(colors::fg_secondary()) + .into_element() + } else { + Markdown::new(self.body.clone()) + .width(Size::fill()) + .style(MarkdownStyle { + color: colors::fg_primary(), + color_link: colors::code_info(), + color_code: colors::fg_primary(), + background_code: colors::component_bg(), + background_blockquote: colors::component_bg(), + border_blockquote: colors::brand(), + background_divider: colors::component_border(), + headings: [18., 16., 14., 13., 12., 12.], + paragraph_size: 12., + code_font_size: 11., + ..MarkdownStyle::default() + }) + .into_element() + } })) .into_element() } diff --git a/packages/oneclient_core/src/changelog.rs b/packages/oneclient_core/src/changelog.rs index 9d55a9de..00d36b2b 100644 --- a/packages/oneclient_core/src/changelog.rs +++ b/packages/oneclient_core/src/changelog.rs @@ -3,60 +3,24 @@ use oneclient_common::paths; use oneclient_net::RequestClient; use oneclient_net::{EtagPolicy, fetch_cached}; -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ChangelogGroup { +const CHANGELOG_URL: &str = + "https://github.com/Polyfrost/OneLauncher/releases/latest/download/changelog.json"; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +pub struct ChangelogEntry { pub version: String, - pub changes: Vec, -} - -pub fn parse_changelog(data: &str) -> Vec { - let mut groups = Vec::new(); - - for line in data.lines() { - if let Some(version) = line.strip_prefix("# ") { - groups.push(ChangelogGroup { - version: version.trim().to_string(), - changes: Vec::new(), - }); - } else if let Some(change) = line.strip_prefix("- ") { - if let Some(group) = groups.last_mut() { - group.changes.push(change.trim().to_string()); - } - } else if line.trim() == "###" { - break; - } - } - - groups + pub body: String, } #[tracing::instrument(level = "debug", skip(net))] -pub async fn fetch_changelog(net: &RequestClient) -> LauncherResult { - let url = format!("{}/oneclient/CHANGE_LOG.md", net.config().meta_url_base); - let cache_path = paths::caches_dir()?.join("CHANGE_LOG.md"); +pub async fn fetch_changelog(net: &RequestClient) -> LauncherResult> { + let cache_path = paths::caches_dir()?.join("changelog.json"); - let fetched = fetch_cached(net, &url, &cache_path, EtagPolicy::CommitNow) + let fetched = fetch_cached(net, CHANGELOG_URL, &cache_path, EtagPolicy::CommitNow) .await? .ok_or_else(|| LauncherError::InvalidSettingsProfile { reason: "changelog is unavailable and not cached".to_string(), })?; - Ok(fetched.text()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_changelog_groups_versions_and_bullets() { - let data = "# 2.0.0\n\n- Faster UI\n- Bug fixes\n# 1.9.0\n- Older change\n###\nignored\n"; - let groups = parse_changelog(data); - - assert_eq!(groups.len(), 2); - assert_eq!(groups[0].version, "2.0.0"); - assert_eq!(groups[0].changes, vec!["Faster UI", "Bug fixes"]); - assert_eq!(groups[1].version, "1.9.0"); - assert_eq!(groups[1].changes, vec!["Older change"]); - } + Ok(fetched.json()?) } diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index ee9bc9ab..44462dc2 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -23,7 +23,7 @@ pub mod tos; pub mod verify; pub mod versions; -pub use changelog::{ChangelogGroup, fetch_changelog, parse_changelog}; +pub use changelog::{ChangelogEntry, fetch_changelog}; pub use clusters::{ Cluster, ClusterError, ClusterManager, ClusterStage, ClusterUpdate, CreateClusterOptions, apply_bundle_java_override, ensure_from_bundles, ensure_from_versions, From c43272683bea1ac94df3bb32606949aa79cbe82c Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:17:52 +0200 Subject: [PATCH 2/3] feat: add code block and update the open link dialogs with the code block (#924) --- ..._are_selectable_and_links_can_be_copied.md | 5 + .../oneclient_app/src/components/button.rs | 26 +++++ .../src/components/code_block.rs | 107 ++++++++++++++++++ .../src/components/link_confirm.rs | 29 +++-- .../src/components/markdown/render.rs | 18 +-- packages/oneclient_app/src/components/mod.rs | 4 +- .../oneclient_app/src/layout/app_shell.rs | 67 ++--------- 7 files changed, 169 insertions(+), 87 deletions(-) create mode 100644 .changeset/code_blocks_are_selectable_and_links_can_be_copied.md create mode 100644 packages/oneclient_app/src/components/code_block.rs diff --git a/.changeset/code_blocks_are_selectable_and_links_can_be_copied.md b/.changeset/code_blocks_are_selectable_and_links_can_be_copied.md new file mode 100644 index 00000000..579bd199 --- /dev/null +++ b/.changeset/code_blocks_are_selectable_and_links_can_be_copied.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +# Code blocks are selectable, and the open link confirmation has a copy button diff --git a/packages/oneclient_app/src/components/button.rs b/packages/oneclient_app/src/components/button.rs index daf48271..bad5dd82 100644 --- a/packages/oneclient_app/src/components/button.rs +++ b/packages/oneclient_app/src/components/button.rs @@ -478,3 +478,29 @@ pub fn open_folder_button(folder: std::path::PathBuf) -> Button { }) .child(Icon::new(IconType::Folder).size(16.)) } + +pub fn copy_button(text: String, copied_body: &'static str, dispatch: crate::Actions) -> Button { + Button::new() + .secondary() + .on_press(move |_| { + if let Err(err) = freya::text_edit::Clipboard::set(text.clone()) { + tracing::warn!("clipboard copy failed: {err:?}"); + dispatch + .notify("Copy failed") + .body("Could not copy to the clipboard.") + .error() + .toast_only() + .send(); + } else { + dispatch + .notify("Copied to clipboard") + .body(copied_body) + .info() + .icon(IconType::ClipboardCheck) + .toast_only() + .send(); + } + }) + .child(Icon::new(IconType::Copy01).size(14.)) + .text("Copy") +} diff --git a/packages/oneclient_app/src/components/code_block.rs b/packages/oneclient_app/src/components/code_block.rs new file mode 100644 index 00000000..7c369b23 --- /dev/null +++ b/packages/oneclient_app/src/components/code_block.rs @@ -0,0 +1,107 @@ +use std::borrow::Cow; + +use freya::prelude::*; + +use crate::components::ScrollArea; +use crate::theme::{self, colors}; +use crate::ui::border_all_color; + +#[derive(PartialEq)] +pub struct CodeBlock { + code: String, + color: Color, + background: Color, + font_family: Cow<'static, str>, + font_size: f32, + max_lines: Option, + height: Option, + key: DiffKey, +} + +impl KeyExt for CodeBlock { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl CodeBlock { + pub fn new(code: impl Into) -> Self { + Self { + code: code.into(), + color: colors::fg_primary(), + background: colors::component_bg(), + font_family: Cow::Borrowed(theme::MONO_FONT), + font_size: 12., + max_lines: None, + height: None, + key: DiffKey::None, + } + } + + pub fn color(mut self, color: Color) -> Self { + self.color = color; + self + } + + pub fn background(mut self, background: Color) -> Self { + self.background = background; + self + } + + pub fn font_family(mut self, font_family: Cow<'static, str>) -> Self { + self.font_family = font_family; + self + } + + pub fn font_size(mut self, font_size: f32) -> Self { + self.font_size = font_size; + self + } + + pub fn max_lines(mut self, max_lines: usize) -> Self { + self.max_lines = Some(max_lines); + self + } + + /// Fixes the block's height and scrolls the code inside it + pub fn height(mut self, height: f32) -> Self { + self.height = Some(height); + self + } +} + +impl Component for CodeBlock { + fn render(&self) -> impl IntoElement { + let text = SelectableText::new() + .span(self.code.clone()) + .font_family(self.font_family.clone()) + .font_size(self.font_size) + .max_lines(self.max_lines) + .width(Size::fill()) + .color(self.color); + + let block = rect() + .width(Size::fill()) + .padding(Gaps::new_all(12.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(self.background) + .border(border_all_color(1., colors::component_border())); + + match self.height { + Some(height) => block + .height(Size::px(height)) + .overflow(Overflow::Clip) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .child(text), + ), + None => block.child(text), + } + } + + fn render_key(&self) -> DiffKey { + self.key.clone().or(self.default_key()) + } +} diff --git a/packages/oneclient_app/src/components/link_confirm.rs b/packages/oneclient_app/src/components/link_confirm.rs index 9b882e61..767d3985 100644 --- a/packages/oneclient_app/src/components/link_confirm.rs +++ b/packages/oneclient_app/src/components/link_confirm.rs @@ -1,7 +1,9 @@ use freya::prelude::*; -use crate::components::{Button, Icon, IconType, OVERLAY_BASE_LEVEL, OverlayPopup}; -use crate::hooks::use_link_confirm; +use crate::components::{ + Button, CodeBlock, Icon, IconType, OVERLAY_BASE_LEVEL, OverlayPopup, copy_button, +}; +use crate::hooks::{use_dispatch, use_link_confirm}; use crate::theme::colors; use crate::ui::border_all_color; @@ -13,6 +15,7 @@ pub struct ConfirmLinkOverlay; impl Component for ConfirmLinkOverlay { fn render(&self) -> impl IntoElement { let mut pending = use_link_confirm(); + let dispatch = use_dispatch(); let Some(url) = pending.read().clone() else { return rect().into_element(); }; @@ -73,20 +76,9 @@ impl Component for ConfirmLinkOverlay { .color(colors::fg_secondary()), ) .child( - rect() - .width(Size::fill()) - .padding(Gaps::new_all(10.)) - .corner_radius(CornerRadius::new_all(8.)) - .background(colors::component_bg()) - .border(border_all_color(1., colors::component_border())) - .child( - label() - .text(url.clone()) - .font_size(12.) - .max_lines(4) - .width(Size::fill()) - .color(colors::code_info()), - ), + CodeBlock::new(url.clone()) + .color(colors::code_info()) + .max_lines(4), ) .child( rect() @@ -94,6 +86,11 @@ impl Component for ConfirmLinkOverlay { .width(Size::fill()) .main_align(Alignment::End) .spacing(8.) + .child(copy_button( + url.clone(), + "Link copied to your clipboard.", + dispatch, + )) .child( Button::new() .secondary() diff --git a/packages/oneclient_app/src/components/markdown/render.rs b/packages/oneclient_app/src/components/markdown/render.rs index 0461f7d1..1ba22ca6 100644 --- a/packages/oneclient_app/src/components/markdown/render.rs +++ b/packages/oneclient_app/src/components/markdown/render.rs @@ -3,6 +3,7 @@ use freya::prelude::*; use super::MarkdownStyle; use super::image::MarkdownImage; use super::parse::{Block, Inline, List, TextSpan}; +use crate::components::CodeBlock; use crate::hooks::LinkConfirmState; pub fn render_block(block: &Block, key: usize, style: &MarkdownStyle) -> Element { @@ -19,19 +20,12 @@ pub fn render_block(block: &Block, key: usize, style: &MarkdownStyle) -> Element Block::Paragraph { content } => render_content(content, style.paragraph_size, style) .key(key) .into(), - Block::Code { code } => rect() - .key(key) - .width(Size::fill()) + Block::Code { code } => CodeBlock::new(code.clone()) + .color(style.color_code) .background(style.background_code) - .corner_radius(CornerRadius::new_all(6.)) - .padding(Gaps::new_all(12.)) - .child( - label() - .text(code.clone()) - .font_family(style.code_font_family.clone()) - .font_size(style.code_font_size) - .color(style.color_code), - ) + .font_family(style.code_font_family.clone()) + .font_size(style.code_font_size) + .key(key) .into(), Block::List(list) => render_list(list, style).key(key).into(), Block::Image { url, alt } => rect() diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 64b1c8c5..fc519e2d 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -7,6 +7,7 @@ mod button; mod charts; mod checkbox; mod cluster_update_popup; +mod code_block; mod context_menu; mod control_center; mod date_range_picker; @@ -62,10 +63,11 @@ pub use active_cluster_panel::ActiveClusterPanel; pub use asset_image::AssetImage; pub use avatar::Avatar; pub use blocked_downloads_popup::BlockedDownloadsPopup; -pub use button::{Button, ButtonSize, ButtonVariant, link_button, open_folder_button}; +pub use button::{Button, ButtonSize, ButtonVariant, copy_button, link_button, open_folder_button}; pub use charts::{BarChart, PieChart, ValueUnit, slice_color}; pub use checkbox::checkbox_labeled; pub use cluster_update_popup::ClusterUpdatePopup; +pub use code_block::CodeBlock; pub use context_menu::ContextMenu; pub use control_center::ControlCenter; pub use date_range_picker::{DateRange, DateRangePicker}; diff --git a/packages/oneclient_app/src/layout/app_shell.rs b/packages/oneclient_app/src/layout/app_shell.rs index 9b206f5a..6164dfb0 100644 --- a/packages/oneclient_app/src/layout/app_shell.rs +++ b/packages/oneclient_app/src/layout/app_shell.rs @@ -10,11 +10,10 @@ use skia_safe::sampling_options::CubicResampler; use crate::Route; use crate::components::Button; use crate::components::{ - ART_PREVIEW_EDGE, AppNavbar, DynamicArt, FileDropOverlay, Icon, IconType, OverlayPopup, - ScrollArea, accept_drop, + ART_PREVIEW_EDGE, AppNavbar, CodeBlock, DynamicArt, FileDropOverlay, Icon, IconType, + OverlayPopup, accept_drop, copy_button, }; use crate::layout::AnimatedAppOutlet; -use crate::theme; use crate::use_settings_snapshot; use oneclient_core::clusters::Cluster; use oneclient_core::images::BACKGROUND_IMAGE_EDGE; @@ -121,20 +120,6 @@ struct LaunchErrorDialog { impl Component for LaunchErrorDialog { fn render(&self) -> impl IntoElement { let dispatch = crate::hooks::use_dispatch(); - let lines: Vec = self - .message - .lines() - .map(|line| { - label() - .text(line.to_string()) - .font_family(theme::MONO_FONT) - .font_size(12.) - .width(Size::fill()) - .color(colors::fg_primary()) - .into_element() - }) - .collect(); - let close = dispatch.clone(); let outside_close = dispatch.clone(); let copy_dispatch = dispatch.clone(); @@ -181,19 +166,9 @@ impl Component for LaunchErrorDialog { .color(colors::fg_secondary()), ) .child( - rect() - .width(Size::fill()) - .height(Size::px(260.)) + CodeBlock::new(self.message.clone()) .background(CODE_BG) - .corner_radius(CornerRadius::new_all(8.)) - .padding(Gaps::new_all(12.)) - .overflow(Overflow::Clip) - .child( - ScrollArea::new() - .width(Size::fill()) - .height(Size::fill()) - .children(lines), - ), + .height(260.), ) .child( rect() @@ -201,7 +176,11 @@ impl Component for LaunchErrorDialog { .width(Size::fill()) .main_align(Alignment::End) .spacing(10.) - .child(copy_error_button(&self.message, copy_dispatch)) + .child(copy_button( + self.message.clone(), + "Error message copied to your clipboard.", + copy_dispatch, + )) .child( Button::new() .primary() @@ -214,34 +193,6 @@ impl Component for LaunchErrorDialog { } } -fn copy_error_button(message: &str, dispatch: crate::Actions) -> impl IntoElement { - let message = message.to_string(); - Button::new() - .secondary() - .on_press(move |_| { - if let Err(err) = freya::text_edit::Clipboard::set(message.clone()) { - tracing::warn!("clipboard copy failed: {err:?}"); - dispatch - .notify("Copy failed") - .body("Could not copy the error to the clipboard.") - .error() - .toast_only() - .send(); - } else { - dispatch - .notify("Copied to clipboard") - .body("Error message copied to your clipboard.") - .info() - .icon(IconType::ClipboardCheck) - .toast_only() - .send(); - } - }) - .child(Icon::new(IconType::Copy01).size(14.)) - .text("Copy") - .into_element() -} - pub(crate) fn appshell_overlay(alpha: f32) -> Rect { let alpha = alpha.clamp(0., 1.); let scale = |a: u8| (f32::from(a) * alpha).round() as u8; From b2384afb4eb274b50ae7b4689bfb55efb36d0bef Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:00:44 +0200 Subject: [PATCH 3/3] feat: log censoring (#925) --- ...hide_tokens_and_show_the_launch_command.md | 5 ++ Cargo.lock | 1 + packages/oneclient_cluster/Cargo.toml | 1 + packages/oneclient_cluster/src/logs/manage.rs | 2 +- packages/oneclient_cluster/src/logs/mclogs.rs | 2 +- packages/oneclient_cluster/src/logs/mod.rs | 2 +- packages/oneclient_cluster/src/logs/parse.rs | 16 ++++ packages/oneclient_core/src/game/launch.rs | 84 +++++++++++++++++-- packages/oneclient_core/src/game/tail.rs | 2 +- 9 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 .changeset/game_logs_hide_tokens_and_show_the_launch_command.md diff --git a/.changeset/game_logs_hide_tokens_and_show_the_launch_command.md b/.changeset/game_logs_hide_tokens_and_show_the_launch_command.md new file mode 100644 index 00000000..23fbb876 --- /dev/null +++ b/.changeset/game_logs_hide_tokens_and_show_the_launch_command.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +# Game logs hide account tokens, and the game output starts with the launch command ready to paste into a terminal diff --git a/Cargo.lock b/Cargo.lock index 8b6266ee..62a11a74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4995,6 +4995,7 @@ dependencies = [ "oneclient_db", "oneclient_net", "polyio", + "regex", "reqwest 0.13.5", "serde", "serde_json", diff --git a/packages/oneclient_cluster/Cargo.toml b/packages/oneclient_cluster/Cargo.toml index 9643c8f3..0b918b3c 100644 --- a/packages/oneclient_cluster/Cargo.toml +++ b/packages/oneclient_cluster/Cargo.toml @@ -25,6 +25,7 @@ bytes.workspace = true chrono.workspace = true image.workspace = true reqwest.workspace = true +regex.workspace = true serde.workspace = true serde_repr.workspace = true strum.workspace = true diff --git a/packages/oneclient_cluster/src/logs/manage.rs b/packages/oneclient_cluster/src/logs/manage.rs index c2ad8558..7245b691 100644 --- a/packages/oneclient_cluster/src/logs/manage.rs +++ b/packages/oneclient_cluster/src/logs/manage.rs @@ -109,7 +109,7 @@ pub(super) fn lines_from(content: &str, opts: &ReadOptions) -> Vec { lines.push(LogLine { number: idx + 1, level, - text: raw.to_string(), + text: super::censor(raw).into_owned(), }); } diff --git a/packages/oneclient_cluster/src/logs/mclogs.rs b/packages/oneclient_cluster/src/logs/mclogs.rs index 1dc22673..a174cc69 100644 --- a/packages/oneclient_cluster/src/logs/mclogs.rs +++ b/packages/oneclient_cluster/src/logs/mclogs.rs @@ -32,7 +32,7 @@ pub async fn upload_log_at( path: &Path, ) -> ClusterResult { let path = ensure_allowed(path)?; - let mut content = read_file_string(&path).await?; + let mut content = super::censor(&read_file_string(&path).await?).into_owned(); let line_count = content.lines().count(); if line_count > MAX_LINES { diff --git a/packages/oneclient_cluster/src/logs/mod.rs b/packages/oneclient_cluster/src/logs/mod.rs index c4483dbb..adff7df5 100644 --- a/packages/oneclient_cluster/src/logs/mod.rs +++ b/packages/oneclient_cluster/src/logs/mod.rs @@ -10,7 +10,7 @@ use chrono::{DateTime, Utc}; pub use error::LogsError; pub use manage::{cluster_output_log, delete_log_at, list_cluster_logs, read_log_at}; pub use mclogs::upload_log_at; -pub use parse::parse_level; +pub use parse::{censor, parse_level}; /// No launcher variant on purpose the launcher's own logs live in the log console #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] diff --git a/packages/oneclient_cluster/src/logs/parse.rs b/packages/oneclient_cluster/src/logs/parse.rs index 089afb32..1c8320b2 100644 --- a/packages/oneclient_cluster/src/logs/parse.rs +++ b/packages/oneclient_cluster/src/logs/parse.rs @@ -1,5 +1,21 @@ +use std::borrow::Cow; +use std::sync::LazyLock; + +use regex::Regex; + use super::LogLevel; +static SECRET: LazyLock = LazyLock::new(|| { + Regex::new( + r#"(--accessToken|--session(?:Id)?)([\s,=]+)[^\s,\[\]'"]+|(token:)[^:\s]+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*"#, + ) + .expect("secret pattern") +}); + +pub fn censor(line: &str) -> Cow<'_, str> { + SECRET.replace_all(line, "${1}${2}${3}[censored]") +} + pub fn parse_level(line: &str) -> Option { let bytes = line.as_bytes(); let mut i = 0; diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index ca2dec8d..dc0195d2 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -1,3 +1,4 @@ +use std::fmt::Write as _; use std::path::Path; use std::process::Stdio; use std::sync::Arc; @@ -5,7 +6,7 @@ use std::time::Duration; use chrono::{DateTime, Utc}; use interfrost::api::minecraft::ArgumentType; -use tokio::io::AsyncReadExt; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::process::Command; use crate::ClusterStage; @@ -395,6 +396,14 @@ async fn start( .args(mc_args) .current_dir(&cwd); + let header = format!( + "OneClient {} · {} · {version_name} · Java {}\n\n{}\n\n", + env!("CARGO_PKG_VERSION"), + cluster.name, + java.major, + command_line(command.as_std(), &version_info.main_class), + ); + let log_path = oneclient_cluster::logs::cluster_output_log(&cluster)?; if let Some(parent) = log_path.parent() { polyio::create_dir_all(parent).await.ok(); @@ -405,10 +414,15 @@ async fn start( // The cloned handle shares the file offset // so stdout and stderr interleave instead of overwriting let handles = match tokio::fs::File::create(&log_path).await { - Ok(out) => match out.try_clone().await { - Ok(err) => Ok((out.into_std().await, err.into_std().await)), - Err(err) => Err(err), - }, + Ok(mut out) => { + if let Err(err) = out.write_all(header.as_bytes()).await { + tracing::warn!(cluster_id, error = %err, "failed to write the launch header"); + } + match out.try_clone().await { + Ok(err) => Ok((out.into_std().await, err.into_std().await)), + Err(err) => Err(err), + } + } Err(err) => Err(err), }; @@ -752,6 +766,66 @@ fn base_command(profile: &GameSettingsProfile, java_path: &str) -> (Command, Opt (command, Some(program.to_string())) } +fn command_line(command: &std::process::Command, main_class: &str) -> String { + #[cfg(not(windows))] + const CONTINUATION: &str = " \\\n "; + #[cfg(windows)] + const CONTINUATION: &str = " `\n "; + + let mut out = String::new(); + if let Some(dir) = command.get_current_dir() { + let _ = writeln!(out, "cd {}", shell_quote(&dir.to_string_lossy())); + } + + for (key, value) in command.get_envs() { + let (key, Some(value)) = (key.to_string_lossy(), value) else { + continue; + }; + let value = shell_quote(&value.to_string_lossy()); + #[cfg(not(windows))] + let _ = write!(out, "{key}={value} "); + #[cfg(windows)] + let _ = writeln!(out, "$env:{key} = {value}"); + } + + #[cfg(windows)] + { + out += "& "; + } + out += &shell_quote(&command.get_program().to_string_lossy()); + + for arg in command.get_args() { + let arg = arg.to_string_lossy(); + let arg = oneclient_cluster::logs::censor(&arg); + out += if arg.starts_with('-') || arg == main_class { + CONTINUATION + } else { + " " + }; + out += &shell_quote(&arg); + } + + out +} + +#[cfg(not(windows))] +fn shell_quote(arg: &str) -> String { + let plain = !arg.is_empty() + && arg + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_-./=:,+@%".contains(&b)); + if plain { + arg.to_string() + } else { + format!("'{}'", arg.replace('\'', r"'\''")) + } +} + +#[cfg(windows)] +fn shell_quote(arg: &str) -> String { + format!("'{}'", arg.replace('\'', "''")) +} + fn apply_env(command: &mut Command, profile: &GameSettingsProfile) { command.env_remove("_JAVA_OPTIONS"); diff --git a/packages/oneclient_core/src/game/tail.rs b/packages/oneclient_core/src/game/tail.rs index e63493fb..6fd1cb43 100644 --- a/packages/oneclient_core/src/game/tail.rs +++ b/packages/oneclient_core/src/game/tail.rs @@ -129,7 +129,7 @@ async fn emit( crash_watch: &CrashWatch, ) { // Blank lines are kept so the console reads the way the log does - let text = line.trim_end_matches(['\n', '\r']).to_string(); + let text = oneclient_cluster::logs::censor(line.trim_end_matches(['\n', '\r'])).into_owned(); if let Some(recorder) = recorder { recorder.observe(&text).await; }