From b182f83ade99082aac94c7d1c477d11bdfae4938 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 23 Sep 2026 15:14:02 -0300 Subject: [PATCH 1/3] feat: Tisty's pipelines, winget, and a star asked for once Everything Tisty learned across three releases, brought over in one branch: concurrency groups and timeouts on every job, the Rust cache written only from main, clippy where the cache is already warm, and six actions pinned by SHA. The job that resolves the version no longer receives the signing secrets, only booleans; publish is the one job that raises permissions. The Windows installer no longer ships unsigned binaries inside it: the bundler is handed a signCommand by thumbprint, and the installer is then opened to check that every exe it carries is signed. A tag only publishes if its commit passed CI on main, verify runs before the Store, Homebrew and winget, and the feed fails loudly instead of going quiet when it exists and cannot be read. New: a cache sweep, Dependabot, the resident's composite action, and winget behind two switches, with its manifests ready for the first submission. About says what the app is in four badges and asks for a star once, after a fortnight and three rules; nothing of this reaches the picker. --- .github/actions/prepare/action.yml | 5 +- .github/actions/sidecar/action.yml | 21 + .github/dependabot.yml | 73 +++ .github/workflows/bundle.yml | 22 +- .github/workflows/caches.yml | 61 ++ .github/workflows/ci.yml | 96 +-- .github/workflows/cla.yml | 80 ++- .github/workflows/commits.yml | 13 +- .github/workflows/feed.yml | 20 +- .github/workflows/mutants-sweep.yml | 44 +- .github/workflows/mutants.yml | 33 +- .github/workflows/release.yml | 558 +++++++++++++----- .github/workflows/rules.yml | 32 +- .github/workflows/update-badge.yml | 53 +- app/src-tauri/src/lib.rs | 54 +- app/src/i18n.ts | 26 +- app/src/settings/About.test.tsx | 20 + app/src/settings/About.tsx | 67 ++- app/src/settings/Star.test.tsx | 55 ++ app/src/settings/Star.tsx | 61 ++ crates/linkunbound-core/src/lib.rs | 2 + crates/linkunbound-core/src/prefs.rs | 6 + crates/linkunbound-core/src/star.rs | 88 +++ package-lock.json | 4 +- package.json | 4 + .../rgdevment.LinkUnbound.installer.yaml | 24 + .../rgdevment.LinkUnbound.locale.en-US.yaml | 44 ++ .../rgdevment.LinkUnbound.locale.es.yaml | 35 ++ packaging/winget/rgdevment.LinkUnbound.yaml | 7 + scripts/generations.py | 79 +++ 30 files changed, 1381 insertions(+), 306 deletions(-) create mode 100644 .github/actions/sidecar/action.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/caches.yml create mode 100644 app/src/settings/Star.test.tsx create mode 100644 app/src/settings/Star.tsx create mode 100644 crates/linkunbound-core/src/star.rs create mode 100644 packaging/winget/rgdevment.LinkUnbound.installer.yaml create mode 100644 packaging/winget/rgdevment.LinkUnbound.locale.en-US.yaml create mode 100644 packaging/winget/rgdevment.LinkUnbound.locale.es.yaml create mode 100644 packaging/winget/rgdevment.LinkUnbound.yaml create mode 100644 scripts/generations.py diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml index b772daa..e38613d 100644 --- a/.github/actions/prepare/action.yml +++ b/.github/actions/prepare/action.yml @@ -16,13 +16,14 @@ inputs: runs: using: composite steps: - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: targets: ${{ inputs.targets }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: key: ${{ inputs.cache-key }} + save-if: false - name: Stamp the version shell: bash diff --git a/.github/actions/sidecar/action.yml b/.github/actions/sidecar/action.yml new file mode 100644 index 0000000..c3cfb1b --- /dev/null +++ b/.github/actions/sidecar/action.yml @@ -0,0 +1,21 @@ +name: The resident the picker carries +description: >- + `externalBin` in tauri.conf.json makes the binary a build-time requirement of + the picker, so anything that compiles the workspace — a test, a clippy run — + needs it there first, not only a bundle. + +inputs: + profile: + description: debug or release. + required: false + default: debug + target: + description: Rust target the resident is built for, empty for the host. + required: false + default: "" + +runs: + using: composite + steps: + - shell: bash + run: bash scripts/sidecar.sh "${{ inputs.profile }}" "${{ inputs.target }}" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9df346a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,73 @@ +version: 2 + +updates: + - package-ecosystem: github-actions + directories: + - / + - /.github/actions/prepare + - /.github/actions/sidecar + schedule: + interval: monthly + open-pull-requests-limit: 3 + commit-message: + prefix: "chore" + groups: + actions: + patterns: + - "*" + update-types: + - minor + - patch + actions-majors: + patterns: + - "*" + update-types: + - major + + - package-ecosystem: cargo + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 3 + commit-message: + prefix: "chore" + groups: + rust: + patterns: + - "*" + update-types: + - minor + - patch + rust-majors: + patterns: + - "*" + update-types: + - major + + - package-ecosystem: npm + directory: /app + schedule: + interval: monthly + open-pull-requests-limit: 3 + commit-message: + prefix: "chore" + groups: + picker: + patterns: + - "*" + update-types: + - minor + - patch + picker-majors: + patterns: + - "*" + update-types: + - major + + - package-ecosystem: npm + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 1 + commit-message: + prefix: "chore" diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index e097750..c6bb627 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -29,6 +29,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: CARGO_TERM_COLOR: always @@ -38,9 +42,12 @@ jobs: runs-on: windows-latest timeout-minutes: 45 steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: false - uses: actions/setup-node@v7 with: @@ -98,11 +105,14 @@ jobs: env: TARGET: aarch64-apple-darwin steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: targets: aarch64-apple-darwin - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: false - uses: actions/setup-node@v7 with: diff --git a/.github/workflows/caches.yml b/.github/workflows/caches.yml new file mode 100644 index 0000000..b52c8fe --- /dev/null +++ b/.github/workflows/caches.yml @@ -0,0 +1,61 @@ +name: Caches + +on: + pull_request_target: + types: [closed] + workflow_run: + workflows: ["CI"] + types: [completed] + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + inputs: + dry_run: + description: "List what would go, delete nothing." + type: boolean + default: true + +permissions: + actions: write + +jobs: + sweep: + name: a closed pull request takes its caches with it + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Everything written under this pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + REF: refs/pull/${{ github.event.pull_request.number }}/merge + run: gh cache delete --all --ref "$REF" --succeed-on-no-caches + + generations: + name: one generation of each cache, and nothing a tag left behind + if: >- + github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' + || (github.event_name == 'workflow_run' + && github.event.workflow_run.event == 'push' + && github.event.workflow_run.head_branch == 'main' + && github.event.workflow_run.head_repository.full_name == github.repository + && github.event.workflow_run.conclusion != 'cancelled') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: write + contents: read + concurrency: + group: cache-generations + cancel-in-progress: false + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: What no build will ask for again + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} + run: python3 scripts/generations.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9327905..e97ac68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: CARGO_TERM_COLOR: always RUSTFLAGS: -D warnings @@ -16,38 +20,22 @@ jobs: test: name: test / ${{ matrix.os }} runs-on: ${{ matrix.os }} + timeout-minutes: 45 strategy: fail-fast: false matrix: os: [windows-latest, macos-latest] steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - uses: taiki-e/install-action@nextest - - name: The resident rides along - shell: bash - run: bash scripts/sidecar.sh - - run: cargo nextest run --workspace --no-tests=pass - - run: cargo test --doc --workspace - - lint: - name: fmt + clippy / ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [windows-latest, macos-latest] - steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 - - run: cargo fmt --all --check - - name: The resident rides along - shell: bash - run: bash scripts/sidecar.sh + components: clippy + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: ${{ github.ref == 'refs/heads/main' }} + - uses: ./.github/actions/sidecar + - uses: taiki-e/install-action@nextest - run: cargo clippy --workspace --all-targets - name: Each crate builds on its own, without the features its neighbours bring along shell: bash @@ -55,15 +43,30 @@ jobs: for one in linkunbound-core linkunbound-win linkunbound-mac linkunbound-shell linkunbound-settings; do cargo check -p "$one" --all-targets done + - run: cargo nextest run --workspace --no-tests=pass + - if: runner.os == 'Windows' + run: cargo test --doc --workspace + + lint: + name: fmt + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + components: rustfmt + - run: cargo fmt --all --check picker: name: frontend runs-on: ubuntu-latest + timeout-minutes: 20 defaults: run: working-directory: app steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 22 @@ -77,31 +80,38 @@ jobs: prose: name: markdown runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 22 + cache: npm + cache-dependency-path: package-lock.json - run: npm ci - run: npm run lint:md audit: name: advisories + licences runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: EmbarkStudios/cargo-deny-action@v2 coverage: name: coverage runs-on: ubuntu-latest + timeout-minutes: 40 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: components: llvm-tools-preview - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} - uses: taiki-e/install-action@cargo-llvm-cov - uses: taiki-e/install-action@nextest @@ -143,12 +153,16 @@ jobs: coverage-mac: name: coverage / the Mac crate runs-on: macos-latest + timeout-minutes: 40 steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: components: llvm-tools-preview - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: false - uses: taiki-e/install-action@cargo-llvm-cov - uses: taiki-e/install-action@nextest - name: The Mac crate, and the Mac branches of the core and the picker @@ -165,18 +179,22 @@ jobs: name: sonarcloud needs: [coverage, coverage-mac] runs-on: ubuntu-latest - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + timeout-minutes: 15 + if: >- + github.actor != 'dependabot[bot]' + && (github.event_name == 'push' + || github.event.pull_request.head.repo.full_name == github.repository) steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 with: name: coverage path: . - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 with: name: coverage-mac path: . diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 8c1eced..327f413 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -11,50 +11,44 @@ name: CLA # went unread. Reopening it without that trailer costs a minute. on: - issue_comment: - types: [created] - pull_request_target: - types: [opened, synchronize, reopened] + issue_comment: + types: [created] + pull_request_target: + types: [opened, synchronize, reopened] permissions: - actions: write - contents: read - pull-requests: write - statuses: write + actions: write + contents: read + pull-requests: write + statuses: write jobs: - cla: - name: Check CLA signature - runs-on: ubuntu-latest - # Only react to the signature phrase or to PR events, never to ordinary - # comment traffic. - if: > - (github.event_name == 'issue_comment' && - github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || - github.event_name == 'pull_request_target' - steps: - - name: CLA Assistant - uses: contributor-assistant/github-action@v2.6.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Fine-grained PAT with contents:write on the signatures - # repository. Without it the action cannot record a signature. - PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_SIGNATURES_TOKEN }} - with: - # Scoped per project: one signatures repository serves every - # dual-licensed repo, but a signature given for one CLA must - # never count as consent for another project's. - path-to-signatures: "signatures/linkunbound/v1/cla.json" - path-to-document: "https://github.com/rgdevment/LinkUnbound/blob/main/CLA.md" - branch: "main" - allowlist: rgdevment,dependabot[bot] - remote-organization-name: rgdevment - remote-repository-name: cla-signatures - custom-notsigned-prcomment: > - Thanks for the pull request. LinkUnbound is released under the GPL-3.0 - and offered under separate commercial terms, which requires a signature - from every contributor before code can be merged. You keep the copyright - on your work — see [CLA.md](https://github.com/rgdevment/LinkUnbound/blob/main/CLA.md). - To sign, post a comment on this pull request with exactly: - custom-pr-sign-comment: "I have read the CLA Document and I hereby sign the CLA" - custom-allsigned-prcomment: "All contributors have signed the CLA. Thanks!" + cla: + name: Check CLA signature + runs-on: ubuntu-latest + timeout-minutes: 10 + if: > + (github.event_name == 'issue_comment' && + github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || + github.event_name == 'pull_request_target' + steps: + - name: CLA Assistant + uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_SIGNATURES_TOKEN }} + with: + path-to-signatures: "signatures/linkunbound/v1/cla.json" + path-to-document: "https://github.com/rgdevment/LinkUnbound/blob/main/CLA.md" + branch: "main" + allowlist: rgdevment,dependabot[bot] + remote-organization-name: rgdevment + remote-repository-name: cla-signatures + custom-notsigned-prcomment: > + Thanks for the pull request. LinkUnbound is released under the GPL-3.0 + and offered under separate commercial terms, which requires a signature + from every contributor before code can be merged. You keep the copyright + on your work — see [CLA.md](https://github.com/rgdevment/LinkUnbound/blob/main/CLA.md). + To sign, post a comment on this pull request with exactly: + custom-pr-sign-comment: "I have read the CLA Document and I hereby sign the CLA" + custom-allsigned-prcomment: "All contributors have signed the CLA. Thanks!" diff --git a/.github/workflows/commits.yml b/.github/workflows/commits.yml index 67f4110..b0cb990 100644 --- a/.github/workflows/commits.yml +++ b/.github/workflows/commits.yml @@ -7,12 +7,17 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: conventional: name: Conventional commit messages runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 @@ -29,13 +34,15 @@ jobs: subject=$(git log -1 --format=%s "$sha" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') if ! printf '%s' "$subject" | grep -qE "$pattern"; then - echo "::error::${sha:0:8} does not follow conventional commits: $subject" + echo "::error::${sha:0:8} does not follow conventional commits" + printf ' %s\n' "$subject" failed=1 continue fi if [ "${#subject}" -gt 90 ]; then - echo "::error::${sha:0:8} subject is ${#subject} characters, keep it under 90: $subject" + echo "::error::${sha:0:8} subject is ${#subject} characters, keep it under 90" + printf ' %s\n' "$subject" failed=1 fi done < <(git rev-list --no-merges "$BASE".."$HEAD") diff --git a/.github/workflows/feed.yml b/.github/workflows/feed.yml index d5616fb..bf0b5cc 100644 --- a/.github/workflows/feed.yml +++ b/.github/workflows/feed.yml @@ -20,14 +20,19 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Read it the way an installed copy does env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - sudo apt-get update -qq && sudo apt-get install -y -qq minisign + for attempt in 1 2 3; do + sudo apt-get update -qq && sudo apt-get install -y -qq minisign && break + echo "minisign would not install (${attempt}/3)" + sleep $((attempt * 5)) + done + command -v minisign feed="https://raw.githubusercontent.com/${{ github.repository }}/manifest" # The key every installed copy carries. Verifying against anything else would be @@ -42,7 +47,8 @@ jobs: -q '.[] | select(.isPrerelease == false and .isDraft == false) | .tagName' \ | sed 's/^v//' | grep -E '^[2-9][0-9]*\.' | sort -V | tail -1 || true) - if ! curl -fsSL -H "Cache-Control: no-cache" -o manifest.json "$feed/release-manifest.json"; then + if ! curl -fsSL --retry 5 --retry-all-errors --retry-delay 3 \ + -H "Cache-Control: no-cache" -o manifest.json "$feed/release-manifest.json"; then echo "::error::the feed did not answer. Every copy out there is looking at nothing, \ and will go on looking at nothing until the branch is put back." exit 1 @@ -91,7 +97,8 @@ jobs: # bytes, which answers 200 and fails on every machine in the world. takes() { local what="$1" expected="$2" - if ! curl -fsSL -H "Cache-Control: no-cache" -o channel.json "$feed/$what"; then + if ! curl -fsSL --retry 5 --retry-all-errors --retry-delay 3 \ + -H "Cache-Control: no-cache" -o channel.json "$feed/$what"; then echo "::error::$what did not answer, so what is announced is an update nobody \ can take" return 1 @@ -114,7 +121,7 @@ jobs: for os in $names; do url=$(jq -r --arg os "$os" '.platforms[$os].url' channel.json) jq -r --arg os "$os" '.platforms[$os].signature' channel.json | base64 -d > one.sig - if ! curl -fsSL -o one.bin "$url"; then + if ! curl -fsSL --retry 5 --retry-all-errors --retry-delay 3 -o one.bin "$url"; then echo "::error::the installer for $os in $what does not answer: $url" return 1 fi @@ -139,7 +146,8 @@ jobs: if [ -n "$ahead" ]; then echo "the candidates' channel, announcing $ahead:" takes candidate.json "$ahead" - elif curl -fsSL -o /dev/null "$feed/candidate.json" 2>/dev/null; then + elif curl -fsSL -H "Cache-Control: no-cache" -o /dev/null \ + "$feed/candidate.json" 2>/dev/null; then echo "::error::candidate.json is still being served and the feed announces no \ candidate. A copy that remembers one would be sent to a version nobody is \ publishing any more." diff --git a/.github/workflows/mutants-sweep.yml b/.github/workflows/mutants-sweep.yml index 928942a..1f92282 100644 --- a/.github/workflows/mutants-sweep.yml +++ b/.github/workflows/mutants-sweep.yml @@ -8,6 +8,10 @@ on: permissions: contents: read +concurrency: + group: mutants-sweep + cancel-in-progress: false + env: CARGO_TERM_COLOR: always DESKTOP_LIBS: libgtk-3-dev libayatana-appindicator3-dev libxdo-dev libfontconfig1-dev libfreetype6-dev libxkbcommon-dev libwayland-dev libxcb1-dev @@ -18,9 +22,12 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 120 steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: false - uses: taiki-e/install-action@nextest - uses: taiki-e/install-action@cargo-mutants @@ -54,9 +61,12 @@ jobs: matrix: shard: [1, 2, 3, 4] steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: false - uses: taiki-e/install-action@nextest - uses: taiki-e/install-action@cargo-mutants @@ -96,9 +106,12 @@ jobs: matrix: shard: [1, 2] steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: false - uses: taiki-e/install-action@nextest - uses: taiki-e/install-action@cargo-mutants @@ -141,9 +154,12 @@ jobs: matrix: shard: [1, 2] steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: false - uses: taiki-e/install-action@nextest - uses: taiki-e/install-action@cargo-mutants @@ -200,7 +216,7 @@ jobs: run: working-directory: app steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: @@ -249,7 +265,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/download-artifact@v7 with: diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml index efca9fe..dd7a6b7 100644 --- a/.github/workflows/mutants.yml +++ b/.github/workflows/mutants.yml @@ -12,6 +12,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: CARGO_TERM_COLOR: always @@ -21,7 +25,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 90 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 @@ -34,9 +38,12 @@ jobs: git diff "$from" -- crates/linkunbound-core crates/linkunbound-shell > branch.diff if [ -s branch.diff ]; then echo "any=yes" >> "$GITHUB_OUTPUT"; fi - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable if: steps.touched.outputs.any == 'yes' - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: false if: steps.touched.outputs.any == 'yes' - uses: taiki-e/install-action@nextest if: steps.touched.outputs.any == 'yes' @@ -77,7 +84,7 @@ jobs: runs-on: macos-latest timeout-minutes: 90 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 @@ -90,9 +97,12 @@ jobs: git diff "$from" -- crates > branch.diff if [ -s branch.diff ]; then echo "any=yes" >> "$GITHUB_OUTPUT"; fi - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable if: steps.touched.outputs.any == 'yes' - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: false if: steps.touched.outputs.any == 'yes' - uses: taiki-e/install-action@nextest if: steps.touched.outputs.any == 'yes' @@ -127,7 +137,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 90 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 @@ -140,9 +150,12 @@ jobs: git diff "$from" -- app/src-tauri/src > branch.diff if [ -s branch.diff ]; then echo "any=yes" >> "$GITHUB_OUTPUT"; fi - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable if: steps.touched.outputs.any == 'yes' - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: false if: steps.touched.outputs.any == 'yes' - uses: taiki-e/install-action@nextest if: steps.touched.outputs.any == 'yes' @@ -187,7 +200,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d67e8eb..7b164b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,12 +11,10 @@ on: default: "0.0.0-dispatch" permissions: - contents: write - attestations: write - id-token: write + contents: read concurrency: - group: release + group: release-${{ github.ref }} cancel-in-progress: false env: @@ -27,12 +25,16 @@ jobs: gate: name: Style and prose runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 22 + cache: npm + cache-dependency-path: | + package-lock.json + app/package-lock.json - run: npm ci - run: npm run lint:md - name: Frontend style @@ -41,6 +43,53 @@ jobs: npm ci npm run lint + tested: + name: Tested when it landed + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + contents: read + actions: read + env: + GH_REPO: ${{ github.repository }} + steps: + - name: This commit was tested when it landed + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + for attempt in $(seq 1 40); do + if ! said=$(gh run list --workflow ci.yml --commit "$GITHUB_SHA" --event push \ + --limit 1 --json status,conclusion \ + --jq 'if length == 0 then "" else .[0] | "\(.status) \(.conclusion)" end' \ + 2>"$RUNNER_TEMP/gh.err"); then + echo "::error::the run history could not be read: $(cat "$RUNNER_TEMP/gh.err")" + exit 1 + fi + case "$said" in + "completed success") + echo "the commit this tag names passed CI on main" + exit 0 + ;; + "completed "*) + echo "::error::CI on ${GITHUB_SHA:0:8} ended ${said#completed }, so this tag is \ + not a release" + exit 1 + ;; + "") + echo "no CI run for ${GITHUB_SHA:0:8} yet (${attempt}/40)" + sleep 30 + ;; + *) + echo "CI on ${GITHUB_SHA:0:8} is still running (${attempt}/40)" + sleep 30 + ;; + esac + done + echo "::error::twenty minutes and CI on ${GITHUB_SHA:0:8} has not finished, or never \ + ran: a tag belongs on a commit that reached main and was tested there" + exit 1 + rust: name: fmt + clippy runs-on: windows-latest @@ -48,15 +97,16 @@ jobs: env: RUSTFLAGS: -D warnings steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: false + - uses: ./.github/actions/sidecar - run: cargo fmt --all --check - - name: The resident rides along - shell: bash - run: bash scripts/sidecar.sh - run: cargo clippy --workspace --all-targets version: @@ -69,67 +119,74 @@ jobs: prerelease: ${{ steps.resolve.outputs.prerelease }} macos: ${{ steps.resolve.outputs.macos }} env: - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - PFX_BASE64: ${{ secrets.PFX_BASE64 }} - PFX_PASSWORD: ${{ secrets.PFX_PASSWORD }} - MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }} - MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + HAS_P12: ${{ secrets.MACOS_CERTIFICATE_P12 != '' }} + HAS_P12_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD != '' }} + HAS_APPLE_ID: ${{ secrets.APPLE_ID != '' }} + HAS_APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD != '' }} + HAS_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID != '' }} + HAS_APPLE_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY != '' }} + HAS_UPDATER_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} + HAS_UPDATER_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD != '' }} + HAS_PFX: ${{ secrets.PFX_BASE64 != '' }} + HAS_PFX_PASSWORD: ${{ secrets.PFX_PASSWORD != '' }} steps: - name: Signing secrets come in pairs run: | fail() { echo "::error::$1"; exit 1; } - if [ -n "$PFX_BASE64" ] && [ -z "$PFX_PASSWORD" ]; then + if [ "$HAS_PFX" = "true" ] && [ "$HAS_PFX_PASSWORD" != "true" ]; then fail "PFX_BASE64 is set but PFX_PASSWORD is empty" fi - if [ -n "$MACOS_CERTIFICATE_P12" ] && [ -z "$MACOS_CERTIFICATE_PASSWORD" ]; then + if [ "$HAS_P12" = "true" ] && [ "$HAS_P12_PASSWORD" != "true" ]; then fail "MACOS_CERTIFICATE_P12 is set but MACOS_CERTIFICATE_PASSWORD is empty" fi - if [ -n "$APPLE_ID" ] && { [ -z "$APPLE_APP_PASSWORD" ] || [ -z "$APPLE_TEAM_ID" ]; }; then + if [ "$HAS_APPLE_ID" = "true" ] && + { [ "$HAS_APPLE_APP_PASSWORD" != "true" ] || [ "$HAS_APPLE_TEAM_ID" != "true" ]; }; then fail "APPLE_ID is set but APPLE_APP_PASSWORD or APPLE_TEAM_ID is empty" fi - if [ -n "$MACOS_CERTIFICATE_P12" ] && [ -z "$APPLE_SIGNING_IDENTITY" ]; then + if [ "$HAS_P12" = "true" ] && [ "$HAS_APPLE_IDENTITY" != "true" ]; then fail "MACOS_CERTIFICATE_P12 is set but APPLE_SIGNING_IDENTITY is empty: the app would be bundled unsigned" fi - if [ "${{ github.event_name }}" = "push" ] && [ "$BUILD_MACOS" = "true" ]; then - if [ -z "$MACOS_CERTIFICATE_P12" ] || [ -z "$APPLE_ID" ]; then - fail "no Apple signing or notarization secrets. A disk image that is not notarized \ - is one Gatekeeper refuses, and the README promises otherwise." - fi - fi - if [ "${{ github.event_name }}" = "push" ] && [ -z "$PFX_BASE64" ]; then - fail "no Windows signing certificate. Set PFX_BASE64 and PFX_PASSWORD, or the \ - installer goes out unsigned." - fi - if [ -n "$TAURI_SIGNING_PRIVATE_KEY" ] && [ -z "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" ]; then + if [ "$HAS_UPDATER_KEY" = "true" ] && [ "$HAS_UPDATER_PASSWORD" != "true" ]; then echo "::notice::the updater key carries no password; that is fine if it was made without one" fi - if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then - if [ "${{ github.event_name }}" = "push" ]; then - fail "no updater key. Every copy already out there would be told about this release \ - and none could take it, and a release is not something an installed copy learns was \ - taken back. Set TAURI_SIGNING_PRIVATE_KEY." + if [ "$HAS_UPDATER_KEY" != "true" ]; then + echo "::warning::no updater key; this release cannot be installed by an existing copy" + fi + + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + missing="" + want() { [ "$2" = "true" ] || missing="$missing $1"; } + want TAURI_SIGNING_PRIVATE_KEY "$HAS_UPDATER_KEY" + want PFX_BASE64 "$HAS_PFX" + want PFX_PASSWORD "$HAS_PFX_PASSWORD" + if [ "$BUILD_MACOS" = "true" ]; then + want MACOS_CERTIFICATE_P12 "$HAS_P12" + want MACOS_CERTIFICATE_PASSWORD "$HAS_P12_PASSWORD" + want APPLE_SIGNING_IDENTITY "$HAS_APPLE_IDENTITY" + want APPLE_ID "$HAS_APPLE_ID" + want APPLE_APP_PASSWORD "$HAS_APPLE_APP_PASSWORD" + want APPLE_TEAM_ID "$HAS_APPLE_TEAM_ID" fi - echo "::warning::no updater key; this build cannot be installed by an existing copy" + [ -z "$missing" ] || fail "this tag would be released without:$missing" fi + echo "signing secrets are coherent" - id: resolve + env: + ASKED_FOR: ${{ inputs.version }} run: | if [[ "$GITHUB_REF" =~ ^refs/tags/v(.+)$ ]]; then version="${BASH_REMATCH[1]}" else - version="${{ inputs.version }}" + version="$ASKED_FOR" fi - num='(0|[1-9][0-9]*)' - pre='(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)' - if [[ ! "$version" =~ ^$num\.$num\.$num(-$pre(\.$pre)*)?$ ]]; then + strict='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' + strict+='(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)' + strict+='(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$' + if [[ ! "$version" =~ $strict ]]; then echo "::error::«$version» is not a version semver reads (1.2.3 or 1.2.3-rc.1)" exit 1 fi @@ -164,7 +221,7 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: ./.github/actions/prepare with: @@ -181,9 +238,36 @@ jobs: working-directory: app run: npm ci - - name: The resident rides along - shell: bash - run: bash scripts/sidecar.sh release + - uses: ./.github/actions/sidecar + with: + profile: release + + - name: Teach the bundler to sign what it packs + if: env.PFX_BASE64 != '' + shell: pwsh + run: | + $pfx = Join-Path $env:RUNNER_TEMP "bundler.pfx" + Set-Content -Path $pfx -Value ([Convert]::FromBase64String($env:PFX_BASE64)) -AsByteStream + try { + $said = ConvertTo-SecureString $env:PFX_PASSWORD -AsPlainText -Force + $cert = @(Import-PfxCertificate -FilePath $pfx ` + -CertStoreLocation Cert:\CurrentUser\My -Password $said)[0] + } finally { + Remove-Item $pfx -ErrorAction SilentlyContinue + } + + $signtool = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" ` + -Recurse -Filter "signtool.exe" | + Where-Object { $_.FullName -match "x64" } | + Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $signtool) { Write-Error "signtool not found"; exit 1 } + + @{ bundle = @{ windows = @{ signCommand = @{ + cmd = $signtool.FullName + args = @("sign", "/sha1", $cert.Thumbprint, "/fd", "sha256", + "/tr", "http://timestamp.digicert.com", "/td", "sha256", "%1") + } } } } | ConvertTo-Json -Depth 8 | Set-Content -Path app/sign.json -Encoding utf8 + Write-Host "the bundler signs with $($cert.Thumbprint)" - name: Bundle working-directory: app @@ -191,7 +275,9 @@ jobs: run: | sign="" if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then sign="--no-sign"; fi - npm run tauri build -- --bundles nsis $sign + packs="" + if [ -f sign.json ]; then packs="--config sign.json"; fi + npm run tauri build -- --bundles nsis $sign $packs - name: The installer carries both binaries shell: bash @@ -203,18 +289,82 @@ jobs: fi script=$(ls target/release/nsis/*/installer.nsi | head -1) if [ -z "$script" ] || ! grep -q 'oname=linkunbound-shell.exe' "$script"; then - echo "::error::the generated installer script does not lay down the resident; the sidecar was not bundled" + echo "::error::the generated installer script does not lay down the resident; the sidecar was not bundled" exit 1 fi echo "the installer lays down the resident" - name: Set the binaries aside for the MSIX - id: stage shell: bash run: | mkdir -p stage cp target/release/linkunbound-settings.exe stage/ cp target/release/linkunbound-shell.exe stage/ + + - name: Sign what the MSIX will carry + if: env.PFX_BASE64 != '' + shell: pwsh + run: | + $bytes = [Convert]::FromBase64String($env:PFX_BASE64) + Set-Content -Path signingCert.pfx -Value $bytes -AsByteStream + try { + $signtool = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" ` + -Recurse -Filter "signtool.exe" | + Where-Object { $_.FullName -like "*x64*" } | + Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $signtool) { Write-Error "signtool not found"; exit 1 } + + & $signtool.FullName sign /f signingCert.pfx /p $env:PFX_PASSWORD ` + /tr http://timestamp.digicert.com /td sha256 /fd sha256 ` + stage/linkunbound-settings.exe stage/linkunbound-shell.exe + if ($LASTEXITCODE -ne 0) { Write-Error "signing failed"; exit 1 } + } finally { + Remove-Item signingCert.pfx -ErrorAction SilentlyContinue + } + + - name: What the installer will put on disk is signed + if: env.PFX_BASE64 != '' + shell: pwsh + run: | + $setup = Get-ChildItem -Path "target/release/bundle/nsis" -Filter "*-setup.exe" | + Select-Object -First 1 + if (-not $setup) { Write-Error "no installer to open"; exit 1 } + + $seven = @( + "C:\Program Files\7-Zip\7z.exe", + "C:\Program Files (x86)\7-Zip\7z.exe" + ) | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $seven) { + $seven = (Get-Command 7z, 7za -ErrorAction SilentlyContinue | + Select-Object -First 1).Source + } + if (-not $seven) { Write-Error "no 7-Zip to open the installer with"; exit 1 } + + $peek = Join-Path $env:RUNNER_TEMP "peek" + & $seven x $setup.FullName "-o$peek" -y | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Error "the installer could not be opened"; exit 1 } + + $mine = Get-ChildItem -Path $peek -Recurse -File | + Where-Object { $_.Extension -eq ".exe" -and $_.FullName -notmatch [regex]::Escape('$PLUGINSDIR') } + if (-not $mine) { Write-Error "the installer holds nothing to run"; exit 1 } + + $status = 0 + foreach ($one in $mine) { + $where = $one.FullName.Substring($peek.Length).TrimStart("\\") + $sig = Get-AuthenticodeSignature $one.FullName + if ($sig.SignerCertificate) { + Write-Host "$where <- $($sig.SignerCertificate.Subject)" + } else { + Write-Error "$where goes out unsigned" + $status = 1 + } + } + exit $status + + - name: What the MSIX job must find unchanged + id: stage + shell: bash + run: | echo "sha=$(cat stage/linkunbound-settings.exe stage/linkunbound-shell.exe | sha256sum | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - uses: actions/upload-artifact@v7 @@ -280,6 +430,7 @@ jobs: with: name: bundle-windows path: dist/* + retention-days: 7 if-no-files-found: error bundle-macos: @@ -306,7 +457,7 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: ./.github/actions/prepare with: @@ -329,8 +480,10 @@ jobs: floor=$(jq -r '.bundle.macOS.minimumSystemVersion' app/src-tauri/tauri.conf.json) echo "MACOSX_DEPLOYMENT_TARGET=$floor" >> "$GITHUB_ENV" - - name: The resident rides along - run: bash scripts/sidecar.sh release "$TARGET" + - uses: ./.github/actions/sidecar + with: + profile: release + target: ${{ matrix.target }} - name: Import signing certificate if: env.MACOS_CERTIFICATE_P12 != '' @@ -476,6 +629,7 @@ jobs: with: name: bundle-macos-${{ matrix.arch }} path: dist/* + retention-days: 7 if-no-files-found: error - name: Cleanup keychain @@ -506,10 +660,10 @@ jobs: echo "go=yes" >> "$GITHUB_OUTPUT" fi - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 if: steps.gate.outputs.go == 'yes' - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 if: steps.gate.outputs.go == 'yes' with: name: stage-windows @@ -587,25 +741,34 @@ jobs: with: name: bundle-msix path: dist/* + retention-days: 7 if-no-files-found: error publish: name: GitHub Release - needs: [rust, version, bundle-windows, bundle-macos, bundle-msix] + needs: [rust, tested, version, bundle-windows, bundle-macos, bundle-msix] if: >- !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - && needs.rust.result == 'success' && needs.version.result == 'success' + && needs.rust.result == 'success' && needs.tested.result == 'success' + && needs.version.result == 'success' && needs.bundle-windows.result == 'success' && (needs.bundle-macos.result == 'success' || needs.bundle-macos.result == 'skipped') && (needs.bundle-msix.result == 'success' || needs.bundle-msix.result == 'skipped') runs-on: ubuntu-latest timeout-minutes: 60 + concurrency: + group: release-publish-${{ github.ref }} + cancel-in-progress: false + permissions: + contents: write + id-token: write + attestations: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 with: pattern: bundle-* path: dist @@ -644,7 +807,12 @@ jobs: - name: The signature answers to the key this build ships run: | - sudo apt-get update -qq && sudo apt-get install -y -qq minisign + for attempt in 1 2 3; do + sudo apt-get update -qq && sudo apt-get install -y -qq minisign && break + echo "minisign would not install (${attempt}/3)" + sleep $((attempt * 5)) + done + command -v minisign v="${{ needs.version.outputs.version }}" jq -r '.plugins.updater.pubkey' app/src-tauri/tauri.conf.json | base64 -d > feed.pub @@ -664,18 +832,11 @@ jobs: echo "the signatures and the shipped key are halves of the same pair" - name: Checksums - run: | - cd dist - shopt -s nullglob - for one in linkunbound-*; do - case "$one" in *.sha256) continue ;; esac - sha256sum "$one" > "$one.sha256" - done - ls -l + run: cd dist && sha256sum * > SHA256SUMS && cat SHA256SUMS - - uses: actions/attest-build-provenance@v4 + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4 with: - subject-path: dist/linkunbound-* + subject-path: dist/linkunbound-*,dist/SHA256SUMS - name: Notes from the tag id: notes @@ -683,16 +844,21 @@ jobs: body=$(git tag -l --format='%(contents:subject)%0a%0a%(contents:body)' "${GITHUB_REF_NAME}" \ | sed '/-----BEGIN SSH SIGNATURE-----/,$d' \ | sed -e :a -e '/^\n*$/{$d;N;ba}') + edge="LU_EOF_$(openssl rand -hex 8)" { - echo "body<> "$GITHUB_OUTPUT" - name: The feed does not walk backwards run: | v="${{ needs.version.outputs.version }}" if ! git fetch origin manifest --depth 1 2>/dev/null; then + if git ls-remote --exit-code --heads origin manifest >/dev/null 2>&1; then + echo "::error::the feed exists and could not be read, so it cannot be checked" + exit 1 + fi echo "no feed yet" exit 0 fi @@ -779,6 +945,11 @@ jobs: ws=$(cat "dist/$win.sig") as=$(cat "dist/$arm.sig" 2>/dev/null || true) is=$(cat "dist/$intel.sig" 2>/dev/null || true) + if [ "${{ needs.version.outputs.macos }}" = "true" ] \ + && { [ -z "$as" ] || [ -z "$is" ]; }; then + echo "::error::no updater signature for a Mac, so no Mac would be offered $v" + exit 1 + fi jq -n --arg v "$v" \ --arg n "https://github.com/${{ github.repository }}/releases/tag/${GITHUB_REF_NAME}" \ --arg wu "$base/$win" --arg ws "$ws" \ @@ -818,15 +989,19 @@ jobs: fi latest=$(jq -r '.latest // ""' release-manifest.json 2>/dev/null || echo "") + ahead=$(jq -r '.latestPrerelease // ""' release-manifest.json 2>/dev/null || echo "") if [ "${{ needs.version.outputs.prerelease }}" = "true" ]; then jq -n --arg l "${latest:-0.0.0}" --arg c "$v" \ '{schema: 1, latest: $l, latestPrerelease: $c}' > release-manifest.json cp "$RUNNER_TEMP/channel.json" candidate.json else - ahead=$(jq -r '.latestPrerelease // ""' release-manifest.json 2>/dev/null || echo "") + keep="" if [ -n "$ahead" ] \ - && [ "$(printf '%s\n%s\n' "$ahead" "$v" | sort -V | tail -1)" = "$ahead" ]; then - jq -n --arg l "$v" --arg c "$ahead" \ + && [ "$(printf '%s\n%s\n' "${ahead%%-*}" "$v" | sort -V | tail -1)" != "$v" ]; then + keep="$ahead" + fi + if [ -n "$keep" ]; then + jq -n --arg l "$v" --arg c "$keep" \ '{schema: 1, latest: $l, latestPrerelease: $c}' > release-manifest.json else jq -n --arg l "$v" '{schema: 1, latest: $l}' > release-manifest.json @@ -851,22 +1026,116 @@ jobs: exit 0 fi echo "the feed would not push (${attempt}/5)" - git fetch origin manifest && git rebase origin/manifest || true + git fetch origin manifest || true + git rebase FETCH_HEAD || git rebase --abort || true sleep $((attempt * 5)) done echo "::error::the feed could not be pushed; the release is out and nothing announces it" exit 1 + verify: + name: The update is installable + needs: [version, publish] + if: >- + !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + && needs.version.result == 'success' && needs.publish.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + + - name: Take the update the way an installed copy would + run: | + for attempt in 1 2 3; do + sudo apt-get update -qq && sudo apt-get install -y -qq minisign && break + echo "minisign would not install (${attempt}/3)" + sleep $((attempt * 5)) + done + command -v minisign + v="${{ needs.version.outputs.version }}" + file=latest.json + if [ "${{ needs.version.outputs.prerelease }}" = "true" ]; then file=candidate.json; fi + feed="https://raw.githubusercontent.com/${{ github.repository }}/manifest/$file" + + said="" + for attempt in 1 2 3 4 5 6; do + if curl -fsSL -H "Cache-Control: no-cache" -o channel.json "$feed"; then + said=$(jq -r .version channel.json) + [ "$said" = "$v" ] && break + fi + echo "the channel still says «${said:-nothing}» (${attempt}/6)" + sleep $((attempt * 20)) + done + jq . channel.json + + ahead=$(jq -r .version channel.json 2>/dev/null || echo "") + if [ ! -s channel.json ] && [ "${{ needs.version.outputs.prerelease }}" = "true" ]; then + ahead=$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 3 \ + -H "Cache-Control: no-cache" \ + "https://raw.githubusercontent.com/${{ github.repository }}/manifest/latest.json" \ + | jq -r .version 2>/dev/null || echo "") + fi + + overtaken=no + if printf "%s" "$ahead" | grep -qE "^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$"; then + mine=${v%%-*} + theirs=${ahead%%-*} + if [ "$theirs" != "$mine" ]; then + if [ "$(printf "%s\n%s\n" "$mine" "$theirs" | sort -V | tail -1)" = "$theirs" ]; then + overtaken=yes + fi + elif [ "$v" != "$mine" ] && [ "$ahead" = "$theirs" ]; then + overtaken=yes + elif [ "$v" != "$mine" ] && [ "$ahead" != "$theirs" ] \ + && [ "$(printf "%s\n%s\n" "$v" "$ahead" | sort -V | tail -1)" = "$ahead" ]; then + overtaken=yes + fi + fi + + if [ "$said" != "$v" ] && [ "$overtaken" = "yes" ]; then + echo "::warning::the feed already carries $ahead, so $v was overtaken before it could be taken here. Its signatures were not checked by this run." + exit 0 + fi + + if [ "$said" != "$v" ]; then + echo "::error::$file says «${said:-nothing}», this release is $v" + exit 1 + fi + + jq -r '.plugins.updater.pubkey' app/src-tauri/tauri.conf.json | base64 -d > feed.pub + + for os in $(jq -r '.platforms | keys[]' channel.json); do + url=$(jq -r --arg os "$os" '.platforms[$os].url' channel.json) + jq -r --arg os "$os" '.platforms[$os].signature' channel.json | base64 -d > one.sig + + case "$url" in + "https://github.com/${{ github.repository }}/releases/download/v$v/"*) ;; + *) echo "::error::$os points at $url, which is not the v$v release"; exit 1 ;; + esac + + curl -fsSL --retry 5 --retry-all-errors --retry-delay 3 -o one.bin "$url" + if minisign -Vm one.bin -p feed.pub -x one.sig; then + echo "$os verifies" + else + echo "::error::$os does not verify against the key this build ships" + exit 1 + fi + done + msstore: name: Microsoft Store - needs: [version, publish, bundle-msix] + needs: [version, publish, verify, bundle-msix] if: >- !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && needs.version.result == 'success' && needs.publish.result == 'success' + && needs.verify.result == 'success' && needs.bundle-msix.result == 'success' && needs.version.outputs.prerelease == 'false' runs-on: windows-latest timeout-minutes: 20 + concurrency: + group: release-msstore-${{ github.ref }} + cancel-in-progress: false env: STORE_PUBLISH: ${{ vars.STORE_PUBLISH }} STORE_APP_ID: ${{ vars.STORE_APP_ID }} @@ -885,20 +1154,21 @@ jobs: elif [ "${{ needs.bundle-msix.outputs.made }}" != "yes" ]; then echo "::notice::no MSIX was built this run; nothing to submit" echo "go=no" >> "$GITHUB_OUTPUT" - elif [ -z "$STORE_APP_ID" ] || [ -z "$STORE_TENANT_ID" ] || [ -z "$STORE_SELLER_ID" ] || [ -z "$STORE_CLIENT_ID" ] || [ -z "$STORE_CLIENT_SECRET" ]; then + elif [ -z "$STORE_APP_ID" ] || [ -z "$STORE_TENANT_ID" ] || [ -z "$STORE_SELLER_ID" ] \ + || [ -z "$STORE_CLIENT_ID" ] || [ -z "$STORE_CLIENT_SECRET" ]; then echo "::notice::the Store credentials are not all here; nothing to submit" echo "go=no" >> "$GITHUB_OUTPUT" else echo "go=yes" >> "$GITHUB_OUTPUT" fi - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 if: steps.gate.outputs.go == 'yes' with: name: bundle-msix path: dist - - uses: microsoft/microsoft-store-apppublisher@v1.3 + - uses: microsoft/microsoft-store-apppublisher@cc9910a8d59f2eb55cbb83df0a3800cf3b5300e0 # v1.4 if: steps.gate.outputs.go == 'yes' with: version: v0.4.2 @@ -942,69 +1212,19 @@ jobs: echo "::error::the package reached the Store but no commit was confirmed. Read the status \ above before tagging again: a submission already committed must not be sent twice." - verify: - name: The update is installable - needs: [version, publish] - if: >- - !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - && needs.version.result == 'success' && needs.publish.result == 'success' - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@v6 - - - name: Take the update the way an installed copy would - run: | - sudo apt-get update -qq && sudo apt-get install -y -qq minisign - v="${{ needs.version.outputs.version }}" - file=latest.json - if [ "${{ needs.version.outputs.prerelease }}" = "true" ]; then file=candidate.json; fi - feed="https://raw.githubusercontent.com/${{ github.repository }}/manifest/$file" - - said="" - for attempt in 1 2 3 4 5 6; do - if curl -fsSL -H "Cache-Control: no-cache" -o channel.json "$feed"; then - said=$(jq -r .version channel.json) - [ "$said" = "$v" ] && break - fi - echo "the channel still says «${said:-nothing}» (${attempt}/6)" - sleep $((attempt * 20)) - done - if [ "$said" != "$v" ]; then - echo "::error::$file says «${said:-nothing}», this release is $v" - exit 1 - fi - jq . channel.json - - jq -r '.plugins.updater.pubkey' app/src-tauri/tauri.conf.json | base64 -d > feed.pub - - for os in $(jq -r '.platforms | keys[]' channel.json); do - url=$(jq -r --arg os "$os" '.platforms[$os].url' channel.json) - jq -r --arg os "$os" '.platforms[$os].signature' channel.json | base64 -d > one.sig - - case "$url" in - "https://github.com/${{ github.repository }}/releases/download/v$v/"*) ;; - *) echo "::error::$os points at $url, which is not the v$v release"; exit 1 ;; - esac - - curl -fsSL -o one.bin "$url" - if minisign -Vm one.bin -p feed.pub -x one.sig; then - echo "$os verifies" - else - echo "::error::$os does not verify against the key this build ships" - exit 1 - fi - done - homebrew: name: Homebrew tap - needs: [version, publish] + needs: [version, publish, verify] if: >- !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && needs.version.result == 'success' && needs.publish.result == 'success' + && needs.verify.result == 'success' && needs.version.outputs.macos == 'true' runs-on: ubuntu-latest timeout-minutes: 10 + concurrency: + group: release-homebrew-${{ github.ref }} + cancel-in-progress: false env: GIST_TOKEN: ${{ secrets.GIST_TOKEN }} steps: @@ -1041,7 +1261,9 @@ jobs: ARM_SHA=$(sha256sum "/tmp/$DMG_ARM" | awk '{print $1}') INTEL_SHA=$(sha256sum "/tmp/$DMG_INTEL" | awk '{print $1}') - git clone "https://x-access-token:${GIST_TOKEN}@github.com/rgdevment/homebrew-tap.git" /tmp/tap + git clone https://github.com/rgdevment/homebrew-tap.git /tmp/tap + git -C /tmp/tap config --local http.extraheader \ + "AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GIST_TOKEN" | base64 -w0)" cd /tmp/tap git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -1084,6 +1306,17 @@ jobs: end CASK + if [ "${{ needs.version.outputs.prerelease }}" = "false" ] \ + && [ -f "Casks/${CASK_OTHER}.rb" ]; then + beta=$(sed -n 's/^[[:space:]]*version "\(.*\)"/\1/p' "Casks/${CASK_OTHER}.rb" | head -1) + if [ -n "$beta" ] \ + && [ "$(printf '%s\n%s\n' "${beta%%-*}" "$VERSION" | sort -V | tail -1)" \ + = "$VERSION" ]; then + git rm -q "Casks/${CASK_OTHER}.rb" + echo "retired Casks/${CASK_OTHER}.rb: it still offered $beta, and $VERSION passes it" + fi + fi + git add Casks if git diff --cached --quiet; then echo "tap already at ${VERSION}" @@ -1097,7 +1330,50 @@ jobs: fi echo "push rejected (attempt ${attempt}/5); rebasing onto the concurrent release" git fetch origin main - git rebase origin/main + git rebase origin/main || { git rebase --abort || true; continue; } done echo "::error::could not push the tap after 5 attempts" exit 1 + + winget: + name: Windows Package Manager + needs: [version, publish, verify] + if: >- + !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + && needs.version.result == 'success' && needs.publish.result == 'success' + && needs.verify.result == 'success' + && needs.version.outputs.prerelease == 'false' + runs-on: ubuntu-latest + timeout-minutes: 15 + concurrency: + group: release-winget-${{ github.ref }} + cancel-in-progress: false + env: + WINGET_PUBLISH: ${{ vars.WINGET_PUBLISH }} + WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} + steps: + - name: Is there anything to add a version to + id: gate + shell: bash + run: | + if [ "$WINGET_PUBLISH" != "true" ]; then + echo "::notice::WINGET_PUBLISH is not true; winget-pkgs gets nothing until it is" + echo "go=no" >> "$GITHUB_OUTPUT" + elif [ -z "$WINGET_TOKEN" ]; then + echo "::notice::no winget token; skipping" + echo "go=no" >> "$GITHUB_OUTPUT" + else + echo "go=yes" >> "$GITHUB_OUTPUT" + fi + + - name: Open the pull request that adds this version + if: steps.gate.outputs.go == 'yes' + uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2 + with: + identifier: rgdevment.LinkUnbound + version: ${{ needs.version.outputs.version }} + release-tag: ${{ github.ref_name }} + installers-regex: 'linkunbound-installer-.*-windows-x86_64\.exe$' + max-versions-to-keep: 5 + fork-user: rgdevment-bot + token: ${{ secrets.WINGET_TOKEN }} diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index 718a216..516c56b 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -4,16 +4,31 @@ on: push: branches: [main] pull_request: + schedule: + - cron: "41 5 * * *" + workflow_dispatch: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: conventions: name: Project conventions runs-on: ubuntu-latest + timeout-minutes: 120 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + - run: npm ci - name: The workflows parse run: | @@ -21,7 +36,7 @@ jobs: # diagnosis — and release only ever runs once the tag is already pushed. status=0 for one in .github/workflows/*.yml .github/actions/*/action.yml; do - if ! npx --yes js-yaml "$one" >/dev/null; then + if ! npx --no-install js-yaml "$one" >/dev/null; then echo "::error file=$one::this file is not valid YAML" status=1 fi @@ -109,11 +124,18 @@ jobs: exit 1 fi - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: workspace + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Tests are deterministic run: | - for i in $(seq 1 20); do + turns=1 + if [ "${GITHUB_EVENT_NAME}" = "schedule" ] || [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + turns=20 + fi + for i in $(seq 1 "$turns"); do cargo test -p linkunbound-core --quiet || exit 1 done diff --git a/.github/workflows/update-badge.yml b/.github/workflows/update-badge.yml index 3b4394b..81d0bbc 100644 --- a/.github/workflows/update-badge.yml +++ b/.github/workflows/update-badge.yml @@ -1,29 +1,38 @@ name: Update Downloads Badge on: - schedule: - - cron: "0 */12 * * *" - workflow_dispatch: + schedule: + - cron: "0 */12 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: downloads-badge + cancel-in-progress: false jobs: - update-badge: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v7 + update-badge: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false - - name: Setup uv - uses: astral-sh/setup-uv@v9.0.0 - with: - enable-cache: false + - name: Setup uv + uses: astral-sh/setup-uv@v9.0.0 + with: + enable-cache: false - - name: Update downloads badge - env: - GITHUB_TOKEN: ${{ github.token }} - GIST_ID: ${{ vars.GIST_ID }} - GIST_TOKEN: ${{ secrets.GIST_TOKEN }} - STORE_TENANT_ID: ${{ secrets.STORE_TENANT_ID }} - STORE_CLIENT_ID: ${{ secrets.STORE_CLIENT_ID }} - STORE_CLIENT_SECRET: ${{ secrets.STORE_CLIENT_SECRET }} - STORE_APP_ID: ${{ vars.STORE_APP_ID }} - run: uv run resources/scripts/update_badge.py + - name: Update downloads badge + env: + GITHUB_TOKEN: ${{ github.token }} + GIST_ID: ${{ vars.GIST_ID }} + GIST_TOKEN: ${{ secrets.GIST_TOKEN }} + STORE_TENANT_ID: ${{ secrets.STORE_TENANT_ID }} + STORE_CLIENT_ID: ${{ secrets.STORE_CLIENT_ID }} + STORE_CLIENT_SECRET: ${{ secrets.STORE_CLIENT_SECRET }} + STORE_APP_ID: ${{ vars.STORE_APP_ID }} + run: uv run resources/scripts/update_badge.py diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index d8619e4..2104aa5 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -7,8 +7,8 @@ mod update; use std::sync::Mutex; use linkunbound_core::{ - Browser, Language, Preferences, Rule, Scope, Store, Strings, Target, host_of, merge, normalise, - site_of, + Asking, Browser, Language, Preferences, Rule, Scope, Store, Strings, Target, host_of, merge, + normalise, site_of, }; use serde::Serialize; use tauri::{AppHandle, Emitter, Manager}; @@ -597,7 +597,14 @@ fn spoken(prefs: &Preferences) -> &'static str { #[tauri::command] fn prefs_set(app: AppHandle, prefs: Preferences) -> Result { - store().save_prefs(&prefs).map_err(|e| e.to_string())?; + let store = store(); + let kept = store.prefs(); + let prefs = Preferences { + here_since: kept.here_since, + asked_for_a_star: kept.asked_for_a_star, + ..prefs + }; + store.save_prefs(&prefs).map_err(|e| e.to_string())?; shell::repaint(&app, prefs.theme); Ok(claim(&app, &prefs)) } @@ -718,6 +725,7 @@ struct Build { repository: &'static str, candidates: bool, candidates_apply: bool, + kept_by_the_store: bool, } #[tauri::command] @@ -728,15 +736,49 @@ fn settings_painted(window: tauri::WebviewWindow) { #[tauri::command] fn about() -> Build { let kept = update::looked(store().dir()); + let store_copy = update::route().route == update::Route::Store; Build { version: HERE, license: "GPL-3.0-only", repository: "https://github.com/rgdevment/LinkUnbound", candidates: update::tracking(HERE, kept.candidates), - candidates_apply: update::route().route != update::Route::Store, + candidates_apply: !store_copy, + kept_by_the_store: store_copy, + } +} + +fn now_in_seconds() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since| since.as_secs()) +} + +#[tauri::command] +fn star_due() -> Result { + let store = store(); + let mut prefs = store.prefs(); + let now = now_in_seconds(); + let counted = || store.rules().map(|set| set.rules.len()).unwrap_or_default(); + let decided = linkunbound_core::asking(prefs.asked_for_a_star, prefs.here_since, now, counted); + match decided { + Asking::Start => { + prefs.here_since = Some(now); + store.save_prefs(&prefs).map_err(|e| e.to_string())?; + Ok(false) + } + Asking::Wait => Ok(false), + Asking::Now => Ok(true), } } +#[tauri::command] +fn star_done() -> Result<(), String> { + let store = store(); + let mut prefs = store.prefs(); + prefs.asked_for_a_star = true; + store.save_prefs(&prefs).map_err(|e| e.to_string()) +} + #[cfg(windows)] fn owner(app: &AppHandle) -> Option { app.get_webview_window("settings") @@ -1236,7 +1278,9 @@ pub fn run() { settings_painted, update_ready, update_install, - update_candidates + update_candidates, + star_due, + star_done ]) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_updater::Builder::new().build()) diff --git a/app/src/i18n.ts b/app/src/i18n.ts index bf3458f..3e318c0 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -222,7 +222,18 @@ const ES = { aboutPrivacy: "Sin cuenta, sin publicidad, sin telemetría, sin servidor. Los enlaces que abres no salen de este equipo.", aboutSupportWhy: - "LinkUnbound es gratuito y lo seguirá siendo. Si te ahorra un fastidio al día, un café mantiene el trabajo en marcha.", + "LinkUnbound es gratuito y lo seguirá siendo. Si te ahorra un fastidio al día, una estrella ayuda a que lo encuentren y un café mantiene el trabajo en marcha.", + aboutStar: "Dar una estrella en GitHub", + aboutRate: "Valorar en la Store", + badgeLocal: "Tus datos, tuyos", + badgeOpen: "Código abierto", + badgeFree: "Gratis", + badgeQuiet: "Sin telemetría", + starThanks: "Gracias por usar LinkUnbound.", + starWhy: "Sin telemetría ni anuncios: la gente solo lo encuentra si alguien lo recomienda.", + starGo: "Dar una estrella", + starNo: "No mostrar más", + starLater: "Ahora no", aboutGitHubSponsor: "Patrocinar en GitHub", aboutSponsorSection: "Apoyar a LinkUnbound", aboutRepo: "Abrir el repositorio", @@ -472,7 +483,18 @@ const EN: Record = { aboutPrivacy: "No account, no ads, no telemetry, no server. The links you open never leave this machine.", aboutSupportWhy: - "LinkUnbound is free and will stay free. If it saves you an annoyance a day, a coffee keeps the work going.", + "LinkUnbound is free and will stay free. If it saves you an annoyance a day, a star helps it get found — and a coffee keeps the work going.", + aboutStar: "Star on GitHub", + aboutRate: "Rate it in the Store", + badgeLocal: "Your data, yours", + badgeOpen: "Open source", + badgeFree: "Free", + badgeQuiet: "No telemetry", + starThanks: "Thank you for using LinkUnbound.", + starWhy: "No telemetry, no ads: people only find it when somebody recommends it.", + starGo: "Star on GitHub", + starNo: "Don't show again", + starLater: "Not now", aboutGitHubSponsor: "Sponsor on GitHub", aboutSponsorSection: "Support LinkUnbound", aboutRepo: "Open the repository", diff --git a/app/src/settings/About.test.tsx b/app/src/settings/About.test.tsx index 813067b..0df0571 100644 --- a/app/src/settings/About.test.tsx +++ b/app/src/settings/About.test.tsx @@ -22,6 +22,7 @@ const BUILD = { repository: "https://github.com/rgdevment/LinkUnbound", candidates: false, candidatesApply: true, + keptByTheStore: false, }; const NEWER: Ready = { @@ -117,6 +118,25 @@ describe("about", () => { ]); }); + it("says what the copy is before asking anything of the reader", async () => { + show(); + expect(await screen.findByText("Sin telemetría")).toBeInTheDocument(); + expect(screen.getByText("Código abierto")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Dar una estrella en GitHub/ })).toBeInTheDocument(); + }); + + it("asks for a rating only from a copy the store keeps", async () => { + show(); + expect(await screen.findByText("2.0.0")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Valorar en la Store/ })).toBeNull(); + cleanup(); + + answers({ about: () => Promise.resolve({ ...BUILD, keptByTheStore: true }) }); + show(); + await userEvent.click(await screen.findByRole("button", { name: /Valorar en la Store/ })); + expect(opened).toEqual(["ms-windows-store://review/?ProductId=9N9F7C8Q43KC"]); + }); + it("says the copy is current when nothing newer was found", async () => { show(); expect(screen.getByText("Estás en la última versión")).toBeInTheDocument(); diff --git a/app/src/settings/About.tsx b/app/src/settings/About.tsx index ffde546..b6e18d0 100644 --- a/app/src/settings/About.tsx +++ b/app/src/settings/About.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { type Key, useSpoken, useWords } from "../i18n"; import { offerMoved, saidPlainly } from "../refusal"; import { Card, Line, Switch } from "./parts"; +import Star from "./Star"; import type { Ready, Underway } from "./update"; const REPO = "https://github.com/rgdevment/LinkUnbound"; @@ -11,6 +12,7 @@ const COFFEE = "https://buymeacoffee.com/rgdevment"; const COPYPASTE = "https://github.com/rgdevment/CopyPaste"; const TISTY = "https://github.com/rgdevment/Tisty"; const SPONSOR = "https://github.com/sponsors/rgdevment"; +const RATING = "ms-windows-store://review/?ProductId=9N9F7C8Q43KC"; type Build = { version: string; @@ -18,6 +20,7 @@ type Build = { repository: string; candidates: boolean; candidatesApply: boolean; + keptByTheStore: boolean; }; /// What happens on «Actualizar» — or, when there is no button, what the person has to do instead: @@ -49,14 +52,24 @@ function Rule({ said }: { said: string }) { ); } +function Badge({ said }: { said: string }) { + return ( + + {said} + + ); +} + function Gives({ said, where, + wide, onPick, children, }: { said: string; where: string; + wide?: boolean; onPick: () => void; children: React.ReactNode; }) { @@ -64,7 +77,9 @@ function Gives({ + + + + + + ); +} + +function noop() {} diff --git a/crates/linkunbound-core/src/lib.rs b/crates/linkunbound-core/src/lib.rs index 1295ceb..064c9bc 100644 --- a/crates/linkunbound-core/src/lib.rs +++ b/crates/linkunbound-core/src/lib.rs @@ -11,6 +11,7 @@ mod prefs; mod private; mod report; mod rule; +mod star; mod store; pub mod update; mod url; @@ -33,6 +34,7 @@ pub use prefs::{Locale, PickerStyle, Preferences, Theme}; pub use private::private_flag_for; pub use report::{diagnostics, redact}; pub use rule::{Rule, RuleSet, Scope, Target, site_of}; +pub use star::{Asking, asking}; pub use store::{Store, StoreError, data_dir, unmarked}; pub use url::{ host_of, is_launchable, local_file_parts, local_web_file, local_web_file_extensions, diff --git a/crates/linkunbound-core/src/prefs.rs b/crates/linkunbound-core/src/prefs.rs index 29f34ae..c983555 100644 --- a/crates/linkunbound-core/src/prefs.rs +++ b/crates/linkunbound-core/src/prefs.rs @@ -50,6 +50,10 @@ pub struct Preferences { /// settings window ever asks, and only while it is open. #[serde(default = "yes")] pub looks_for_updates: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub here_since: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub asked_for_a_star: bool, } fn default_shortcut() -> Option { @@ -71,6 +75,8 @@ impl Default for Preferences { edge_warning_dismissed: false, picker_style: PickerStyle::default(), looks_for_updates: true, + here_since: None, + asked_for_a_star: false, } } } diff --git a/crates/linkunbound-core/src/star.rs b/crates/linkunbound-core/src/star.rs new file mode 100644 index 0000000..bf0e5af --- /dev/null +++ b/crates/linkunbound-core/src/star.rs @@ -0,0 +1,88 @@ +pub const SETTLED_IN: u64 = 60 * 60 * 24 * 14; +pub const RULES_ENOUGH: usize = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Asking { + Start, + Wait, + Now, +} + +#[must_use] +pub fn asking(asked: bool, since: Option, now: u64, rules: impl FnOnce() -> usize) -> Asking { + if asked { + return Asking::Wait; + } + let Some(since) = since.filter(|at| *at <= now) else { + return Asking::Start; + }; + if now - since < SETTLED_IN { + return Asking::Wait; + } + if rules() < RULES_ENOUGH { + return Asking::Wait; + } + Asking::Now +} + +#[cfg(test)] +mod tests { + use super::{Asking, RULES_ENOUGH, SETTLED_IN, asking}; + + const NOW: u64 = 1_800_000_000; + + #[test] + fn a_copy_that_was_asked_once_is_never_asked_again() { + assert_eq!(asking(true, Some(0), NOW, || 1000), Asking::Wait); + } + + #[test] + fn a_copy_with_no_mark_lays_one_and_says_nothing_yet() { + assert_eq!(asking(false, None, NOW, || 1000), Asking::Start); + } + + #[test] + fn a_mark_from_a_clock_that_was_ahead_is_laid_again_rather_than_waited_on_for_ever() { + assert_eq!(asking(false, Some(NOW + 1), NOW, || 1000), Asking::Start); + } + + #[test] + fn a_fortnight_is_the_floor_and_the_second_before_it_is_not() { + assert_eq!( + asking(false, Some(NOW - SETTLED_IN + 1), NOW, || 1000), + Asking::Wait + ); + assert_eq!( + asking(false, Some(NOW - SETTLED_IN), NOW, || 1000), + Asking::Now + ); + } + + #[test] + fn somebody_who_taught_it_nothing_is_not_asked_for_anything() { + let long_ago = Some(NOW - SETTLED_IN * 2); + assert_eq!( + asking(false, long_ago, NOW, || RULES_ENOUGH - 1), + Asking::Wait + ); + assert_eq!(asking(false, long_ago, NOW, || RULES_ENOUGH), Asking::Now); + } + + #[test] + fn the_rules_are_not_read_when_the_answer_is_known_without_them() { + let counted = std::cell::Cell::new(0); + let count = || { + counted.set(counted.get() + 1); + 1000 + }; + assert_eq!(asking(true, None, NOW, count), Asking::Wait); + assert_eq!(counted.get(), 0); + + let count = || { + counted.set(counted.get() + 1); + 1000 + }; + assert_eq!(asking(false, Some(NOW), NOW, count), Asking::Wait); + assert_eq!(counted.get(), 0); + } +} diff --git a/package-lock.json b/package-lock.json index 02dd085..928002f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,10 +1,12 @@ { - "name": "LinkUnbound", + "name": "linkunbound", "lockfileVersion": 3, "requires": true, "packages": { "": { + "name": "linkunbound", "devDependencies": { + "js-yaml": "^4.1.0", "markdownlint-cli2": "^0.20.0" } }, diff --git a/package.json b/package.json index 0a0836a..7802259 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,9 @@ { + "name": "linkunbound", + "private": true, + "repository": "github:rgdevment/LinkUnbound", "devDependencies": { + "js-yaml": "^4.1.0", "markdownlint-cli2": "^0.20.0" }, "scripts": { diff --git a/packaging/winget/rgdevment.LinkUnbound.installer.yaml b/packaging/winget/rgdevment.LinkUnbound.installer.yaml new file mode 100644 index 0000000..6b1f9aa --- /dev/null +++ b/packaging/winget/rgdevment.LinkUnbound.installer.yaml @@ -0,0 +1,24 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.12.0.schema.json + +PackageIdentifier: rgdevment.LinkUnbound +PackageVersion: 2.0.2 +Platform: + - Windows.Desktop +MinimumOSVersion: 10.0.17763.0 +InstallerType: nullsoft +Scope: user +InstallModes: + - interactive + - silent + - silentWithProgress +UpgradeBehavior: install +RequireExplicitUpgrade: true +ReleaseDate: 2026-09-21 +InstallationMetadata: + DefaultInstallLocation: '%LOCALAPPDATA%\Programs\LinkUnbound' +Installers: + - Architecture: x64 + InstallerUrl: https://github.com/rgdevment/LinkUnbound/releases/download/v2.0.2/linkunbound-installer-2.0.2-windows-x86_64.exe + InstallerSha256: ECBD1F7DC045DF8913FE14674E899109CB0A70AC281FF8C77963EF1BB16D6CFD +ManifestType: installer +ManifestVersion: 1.12.0 diff --git a/packaging/winget/rgdevment.LinkUnbound.locale.en-US.yaml b/packaging/winget/rgdevment.LinkUnbound.locale.en-US.yaml new file mode 100644 index 0000000..54ef75c --- /dev/null +++ b/packaging/winget/rgdevment.LinkUnbound.locale.en-US.yaml @@ -0,0 +1,44 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.12.0.schema.json + +PackageIdentifier: rgdevment.LinkUnbound +PackageVersion: 2.0.2 +PackageLocale: en-US +Publisher: rgdevment +PublisherUrl: https://github.com/rgdevment +PublisherSupportUrl: https://github.com/rgdevment/LinkUnbound/issues +PrivacyUrl: https://github.com/rgdevment/LinkUnbound/blob/main/PRIVACY.md +Author: Mario Hidalgo G. +PackageName: LinkUnbound +PackageUrl: https://github.com/rgdevment/LinkUnbound +License: GPL-3.0-only +LicenseUrl: https://github.com/rgdevment/LinkUnbound/blob/main/LICENSE +Copyright: Copyright (c) 2026 rgdevment +CopyrightUrl: https://github.com/rgdevment/LinkUnbound/blob/main/LICENSE +ShortDescription: Choose which browser opens every link. +Description: |- + LinkUnbound puts itself between a link and your browsers, so every link opens where you + want it. It becomes the default handler for http and https, and when a link arrives it + asks which browser — or answers on its own, from a rule you taught it. Rules remember + the choices you make, by site, by profile or by the program the link came from. Work in + one browser and personal in another, without moving a default back and forth. Everything + stays on your machine: no account, no telemetry, no server. +Moniker: linkunbound +Tags: + - browser + - default-browser + - links + - local-first + - offline + - open-source + - privacy + - productivity + - url-handler + - utility +ReleaseNotesUrl: https://github.com/rgdevment/LinkUnbound/releases/tag/v2.0.2 +Documentations: + - DocumentLabel: Privacy + DocumentUrl: https://github.com/rgdevment/LinkUnbound/blob/main/PRIVACY.md + - DocumentLabel: Security + DocumentUrl: https://github.com/rgdevment/LinkUnbound/blob/main/SECURITY.md +ManifestType: defaultLocale +ManifestVersion: 1.12.0 diff --git a/packaging/winget/rgdevment.LinkUnbound.locale.es.yaml b/packaging/winget/rgdevment.LinkUnbound.locale.es.yaml new file mode 100644 index 0000000..208b544 --- /dev/null +++ b/packaging/winget/rgdevment.LinkUnbound.locale.es.yaml @@ -0,0 +1,35 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.locale.1.12.0.schema.json + +PackageIdentifier: rgdevment.LinkUnbound +PackageVersion: 2.0.2 +PackageLocale: es +Publisher: rgdevment +PublisherUrl: https://github.com/rgdevment +PublisherSupportUrl: https://github.com/rgdevment/LinkUnbound/issues +PrivacyUrl: https://github.com/rgdevment/LinkUnbound/blob/main/PRIVACY.md +Author: Mario Hidalgo G. +PackageName: LinkUnbound +PackageUrl: https://github.com/rgdevment/LinkUnbound +License: GPL-3.0-only +LicenseUrl: https://github.com/rgdevment/LinkUnbound/blob/main/LICENSE +Copyright: Copyright (c) 2026 rgdevment +ShortDescription: Elige con qué navegador se abre cada enlace. +Description: |- + LinkUnbound se pone entre un enlace y tus navegadores para que cada enlace abra donde tú + quieres. Se registra como la aplicación que atiende http y https, y cuando llega un + enlace pregunta con cuál abrirlo, o responde solo a partir de una regla que le enseñaste. + Las reglas recuerdan tus decisiones por sitio, por perfil o por el programa del que vino + el enlace. Trabaja en un navegador y lo personal en otro, sin cambiar el predeterminado + cada vez. Todo se queda en tu equipo: sin cuenta, sin telemetría, sin servidor. +Tags: + - código-abierto + - enlaces + - navegador + - navegador-predeterminado + - privacidad + - productividad + - sin-conexión + - utilidad +ReleaseNotesUrl: https://github.com/rgdevment/LinkUnbound/releases/tag/v2.0.2 +ManifestType: locale +ManifestVersion: 1.12.0 diff --git a/packaging/winget/rgdevment.LinkUnbound.yaml b/packaging/winget/rgdevment.LinkUnbound.yaml new file mode 100644 index 0000000..2e455bf --- /dev/null +++ b/packaging/winget/rgdevment.LinkUnbound.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.12.0.schema.json + +PackageIdentifier: rgdevment.LinkUnbound +PackageVersion: 2.0.2 +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.12.0 diff --git a/scripts/generations.py b/scripts/generations.py new file mode 100644 index 0000000..743c16a --- /dev/null +++ b/scripts/generations.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +import json +import os +import re +import subprocess +import sys +from datetime import datetime + +MAIN = "refs/heads/main" +TAGS = "refs/heads/refs/tags/" +DRY = os.environ.get("DRY_RUN", "").lower() == "true" +RUST = re.compile(r"^(v0-rust-.+)-[0-9a-f]{8}-[0-9a-f]{8}$") +GONE = "Could not find a cache matching" + + +def gh(*args): + done = subprocess.run(["gh", *args], capture_output=True, text=True) + if done.returncode != 0: + sys.exit(f"gh {' '.join(args)} failed: {done.stderr.strip()}") + return done.stdout + + +def listed(ref=None): + args = ["cache", "list", "--limit", "100", "--json", "key,ref,sizeInBytes,createdAt"] + if ref: + args += ["--ref", ref] + return json.loads(gh(*args)) + + +def superseded(): + households = {} + for one in listed(MAIN): + found = RUST.match(one["key"]) + if found: + households.setdefault(found.group(1), []).append({**one, "ref": MAIN}) + stale = [] + for household in households.values(): + household.sort(key=lambda one: datetime.fromisoformat(one["createdAt"]), reverse=True) + stale.extend(household[1:]) + return stale + + +def petrified(): + return [one for one in listed() if one["ref"].startswith(TAGS)] + + +def main(): + stale = superseded() + petrified() + if not stale: + print("nothing superseded and no tag left anything behind") + return + + freed = 0 + failed = [] + for one in stale: + print(f" {one['sizeInBytes'] // 1048576:>5} MB {one['ref']} {one['key']}") + if DRY: + freed += one["sizeInBytes"] + continue + done = subprocess.run( + ["gh", "cache", "delete", one["key"], "--ref", one["ref"]], + capture_output=True, + text=True, + ) + if done.returncode == 0: + freed += one["sizeInBytes"] + elif GONE in done.stderr: + print(f" evicted before we got to it: {one['key']}") + else: + failed.append(f"{one['key']}: {done.stderr.strip()}") + + said = "would sweep" if DRY else "swept" + print(f"::notice::{said} {len(stale) - len(failed)} cache(s), {freed // 1048576} MB") + if failed: + sys.exit("::error::" + "; ".join(failed)) + + +if __name__ == "__main__": + main() From ea5af8a1ac8c402620980bbbc261407e39ef979a Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 23 Sep 2026 15:43:42 -0300 Subject: [PATCH 2/3] fix: what the audit found in the pipelines, the signing and the card The feed no longer listens for published and released: the release edits the draft before it writes the manifest, so every tag left that workflow red. The Mac coverage job gets its own cache back, Sonar is told to ignore the duplicate shape of the two catalogues, and RUSTFLAGS now matches between the job that writes the shared cache and the ones that read it. No signing secret reaches a job environment any more: the jobs carry booleans and each secret lives on the step that uses it, so npm ci no longer runs with the certificate in front of it. Signing goes by thumbprint everywhere, which retires both the password on a command line and the pfx written to disk, and the check on what the installer lays down now demands our own certificate, a signature the system calls valid, and looks at DLLs too. The tap refuses to write a version older than the one it already offers, so a rerun cannot downgrade a Mac through brew upgrade. The overtaken branch in verify is reachable again, a rerun of an older tag no longer trips on the checksum change, and the MSIX carries the resource index the Store asks of a bilingual package. Dismissing the card now survives changing section, the permanent button steps aside while the card is up, a clock that answers nothing writes no mark, and preferences are written under the same lock that protects the rules. PRIVACY names preferences.json and says what the two marks are. --- .github/workflows/bundle.yml | 1 + .github/workflows/ci.yml | 8 +- .github/workflows/feed.yml | 2 +- .github/workflows/mutants-sweep.yml | 2 + .github/workflows/release.yml | 154 +++++++++++++++++---------- .github/workflows/update-badge.yml | 2 +- PRIVACY.md | 4 + app/src-tauri/src/lib.rs | 71 +++++++----- app/src/Settings.tsx | 16 ++- app/src/i18n.ts | 2 +- app/src/settings/About.test.tsx | 27 ++++- app/src/settings/About.tsx | 35 +++--- app/src/settings/Star.tsx | 5 +- crates/linkunbound-core/src/store.rs | 75 ++++++++++++- scripts/sidecar.sh | 2 +- 15 files changed, 291 insertions(+), 115 deletions(-) diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index c6bb627..2e9e6ba 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -35,6 +35,7 @@ concurrency: env: CARGO_TERM_COLOR: always + RUSTFLAGS: -D warnings jobs: nsis: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e97ac68..54f1812 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,7 +97,7 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@v7 - - uses: EmbarkStudios/cargo-deny-action@v2 + - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2 coverage: name: coverage @@ -161,8 +161,7 @@ jobs: components: llvm-tools-preview - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: - shared-key: workspace - save-if: false + save-if: ${{ github.ref == 'refs/heads/main' }} - uses: taiki-e/install-action@cargo-llvm-cov - uses: taiki-e/install-action@nextest - name: The Mac crate, and the Mac branches of the core and the picker @@ -199,7 +198,7 @@ jobs: name: coverage-mac path: . - - uses: SonarSource/sonarqube-scan-action@v6 + - uses: SonarSource/sonarqube-scan-action@fd88b7d7ccbaefd23d8f36f73b59db7a3d246602 # v6 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} with: @@ -212,6 +211,7 @@ jobs: -Dsonar.exclusions=crates/*/tests/**,app/src/tests/** -Dsonar.rust.lcov.reportPaths=lcov-rust.info,lcov-mac.info -Dsonar.javascript.lcov.reportPaths=app/coverage/lcov.info + -Dsonar.cpd.exclusions=app/src/i18n.ts -Dsonar.rust.clippy.enabled=false -Dsonar.coverage.exclusions=app/src/main.tsx,app/src-tauri/src/** -Dsonar.qualitygate.wait=true diff --git a/.github/workflows/feed.yml b/.github/workflows/feed.yml index bf0b5cc..11dfaf4 100644 --- a/.github/workflows/feed.yml +++ b/.github/workflows/feed.yml @@ -8,7 +8,7 @@ on: schedule: - cron: "23 7 * * *" release: - types: [published, unpublished, deleted, edited, released] + types: [unpublished, deleted, edited] workflow_dispatch: permissions: diff --git a/.github/workflows/mutants-sweep.yml b/.github/workflows/mutants-sweep.yml index 1f92282..be1adef 100644 --- a/.github/workflows/mutants-sweep.yml +++ b/.github/workflows/mutants-sweep.yml @@ -149,6 +149,8 @@ jobs: name: mac / shard ${{ matrix.shard }} of 2 runs-on: macos-latest timeout-minutes: 330 + env: + RUSTFLAGS: -D warnings strategy: fail-fast: false matrix: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b164b2..2f63244 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -216,10 +216,8 @@ jobs: stage: ${{ steps.stage.outputs.sha }} env: TARGET: x86_64-pc-windows-msvc - PFX_BASE64: ${{ secrets.PFX_BASE64 }} - PFX_PASSWORD: ${{ secrets.PFX_PASSWORD }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + HAS_PFX: ${{ secrets.PFX_BASE64 != '' }} + HAS_UPDATER_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} steps: - uses: actions/checkout@v7 @@ -243,8 +241,11 @@ jobs: profile: release - name: Teach the bundler to sign what it packs - if: env.PFX_BASE64 != '' + if: env.HAS_PFX == 'true' shell: pwsh + env: + PFX_BASE64: ${{ secrets.PFX_BASE64 }} + PFX_PASSWORD: ${{ secrets.PFX_PASSWORD }} run: | $pfx = Join-Path $env:RUNNER_TEMP "bundler.pfx" Set-Content -Path $pfx -Value ([Convert]::FromBase64String($env:PFX_BASE64)) -AsByteStream @@ -267,11 +268,15 @@ jobs: args = @("sign", "/sha1", $cert.Thumbprint, "/fd", "sha256", "/tr", "http://timestamp.digicert.com", "/td", "sha256", "%1") } } } } | ConvertTo-Json -Depth 8 | Set-Content -Path app/sign.json -Encoding utf8 + "CERT_THUMBPRINT=$($cert.Thumbprint)" | Out-File -FilePath $env:GITHUB_ENV -Append Write-Host "the bundler signs with $($cert.Thumbprint)" - name: Bundle working-directory: app shell: bash + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | sign="" if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then sign="--no-sign"; fi @@ -302,28 +307,22 @@ jobs: cp target/release/linkunbound-shell.exe stage/ - name: Sign what the MSIX will carry - if: env.PFX_BASE64 != '' + if: env.HAS_PFX == 'true' shell: pwsh run: | - $bytes = [Convert]::FromBase64String($env:PFX_BASE64) - Set-Content -Path signingCert.pfx -Value $bytes -AsByteStream - try { - $signtool = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" ` - -Recurse -Filter "signtool.exe" | - Where-Object { $_.FullName -like "*x64*" } | - Sort-Object FullName -Descending | Select-Object -First 1 - if (-not $signtool) { Write-Error "signtool not found"; exit 1 } - - & $signtool.FullName sign /f signingCert.pfx /p $env:PFX_PASSWORD ` - /tr http://timestamp.digicert.com /td sha256 /fd sha256 ` - stage/linkunbound-settings.exe stage/linkunbound-shell.exe - if ($LASTEXITCODE -ne 0) { Write-Error "signing failed"; exit 1 } - } finally { - Remove-Item signingCert.pfx -ErrorAction SilentlyContinue - } + $signtool = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" ` + -Recurse -Filter "signtool.exe" | + Where-Object { $_.FullName -like "*x64*" } | + Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $signtool) { Write-Error "signtool not found"; exit 1 } + + & $signtool.FullName sign /sha1 $env:CERT_THUMBPRINT /fd sha256 ` + /tr http://timestamp.digicert.com /td sha256 ` + stage/linkunbound-settings.exe stage/linkunbound-shell.exe + if ($LASTEXITCODE -ne 0) { Write-Error "signing failed"; exit 1 } - name: What the installer will put on disk is signed - if: env.PFX_BASE64 != '' + if: env.HAS_PFX == 'true' shell: pwsh run: | $setup = Get-ChildItem -Path "target/release/bundle/nsis" -Filter "*-setup.exe" | @@ -345,18 +344,24 @@ jobs: if ($LASTEXITCODE -ne 0) { Write-Error "the installer could not be opened"; exit 1 } $mine = Get-ChildItem -Path $peek -Recurse -File | - Where-Object { $_.Extension -eq ".exe" -and $_.FullName -notmatch [regex]::Escape('$PLUGINSDIR') } + Where-Object { $_.Extension -in ".exe", ".dll" -and $_.FullName -notmatch [regex]::Escape('$PLUGINSDIR') } if (-not $mine) { Write-Error "the installer holds nothing to run"; exit 1 } $status = 0 foreach ($one in $mine) { $where = $one.FullName.Substring($peek.Length).TrimStart("\\") $sig = Get-AuthenticodeSignature $one.FullName - if ($sig.SignerCertificate) { - Write-Host "$where <- $($sig.SignerCertificate.Subject)" - } else { + if (-not $sig.SignerCertificate) { Write-Error "$where goes out unsigned" $status = 1 + } elseif ($sig.SignerCertificate.Thumbprint -ne $env:CERT_THUMBPRINT) { + Write-Error "$where is signed by $($sig.SignerCertificate.Subject), which is not this build's certificate" + $status = 1 + } elseif ($sig.Status -ne "Valid") { + Write-Error "$where carries a signature the system calls $($sig.Status)" + $status = 1 + } else { + Write-Host "$where <- $($sig.SignerCertificate.Subject)" } } exit $status @@ -375,12 +380,9 @@ jobs: if-no-files-found: error - name: Sign the installer - if: env.PFX_BASE64 != '' + if: env.HAS_PFX == 'true' shell: pwsh run: | - $bytes = [Convert]::FromBase64String($env:PFX_BASE64) - Set-Content -Path signingCert.pfx -Value $bytes -AsByteStream - $signtool = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" ` -Recurse -Filter "signtool.exe" | Where-Object { $_.FullName -like "*x64*" } | @@ -391,15 +393,17 @@ jobs: Select-Object -First 1 if (-not $setup) { Write-Error "no installer to sign"; exit 1 } - & $signtool.FullName sign /f signingCert.pfx /p $env:PFX_PASSWORD ` - /tr http://timestamp.digicert.com /td sha256 /fd sha256 $setup.FullName + & $signtool.FullName sign /sha1 $env:CERT_THUMBPRINT /fd sha256 ` + /tr http://timestamp.digicert.com /td sha256 $setup.FullName if ($LASTEXITCODE -ne 0) { Write-Error "signing failed"; exit 1 } - Remove-Item signingCert.pfx $sig = Get-AuthenticodeSignature $setup.FullName if (-not $sig.TimeStamperCertificate) { Write-Error "no timestamp: the signature would die with the certificate"; exit 1 } + if ($sig.Status -ne "Valid") { + Write-Error "the installer carries a signature the system calls $($sig.Status)"; exit 1 + } Write-Host "status: $($sig.Status)" - name: Name it after the release @@ -416,9 +420,12 @@ jobs: ls -l dist - name: Sign it for the updater - if: env.TAURI_SIGNING_PRIVATE_KEY != '' + if: env.HAS_UPDATER_KEY == 'true' working-directory: app shell: bash + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | v="${{ needs.version.outputs.version }}" exe="../dist/linkunbound-installer-$v-windows-x86_64.exe" @@ -450,12 +457,9 @@ jobs: env: TARGET: ${{ matrix.target }} ARCH: ${{ matrix.arch }} - MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + HAS_P12: ${{ secrets.MACOS_CERTIFICATE_P12 != '' }} + HAS_APPLE_ID: ${{ secrets.APPLE_ID != '' }} + HAS_UPDATER_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} steps: - uses: actions/checkout@v7 @@ -486,8 +490,9 @@ jobs: target: ${{ matrix.target }} - name: Import signing certificate - if: env.MACOS_CERTIFICATE_P12 != '' + if: env.HAS_P12 == 'true' env: + MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }} MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} run: | KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" @@ -509,7 +514,7 @@ jobs: run: npm run tauri build -- --target "$TARGET" --no-bundle - name: Sign the settings binary - if: env.MACOS_CERTIFICATE_P12 != '' + if: env.HAS_P12 == 'true' env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | @@ -521,6 +526,8 @@ jobs: working-directory: app env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | sign="" if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then sign="--no-sign"; fi @@ -556,7 +563,7 @@ jobs: exit $status - name: The app is really signed - if: env.MACOS_CERTIFICATE_P12 != '' + if: env.HAS_P12 == 'true' run: | app="target/$TARGET/release/bundle/macos/LinkUnbound.app" codesign --verify --deep --strict --verbose=2 "$app" @@ -566,7 +573,11 @@ jobs: fi - name: Notarize - if: env.APPLE_ID != '' && env.MACOS_CERTIFICATE_P12 != '' + if: env.HAS_APPLE_ID == 'true' && env.HAS_P12 == 'true' + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | dmg=$(ls target/$TARGET/release/bundle/dmg/*.dmg | head -1) xcrun notarytool submit "$dmg" \ @@ -575,7 +586,11 @@ jobs: xcrun stapler staple "$dmg" - name: Staple the app itself, and tar it again - if: env.APPLE_ID != '' && env.MACOS_CERTIFICATE_P12 != '' + if: env.HAS_APPLE_ID == 'true' && env.HAS_P12 == 'true' + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | out="target/$TARGET/release/bundle/macos" app="$out/LinkUnbound.app" @@ -596,8 +611,11 @@ jobs: xcrun stapler validate "$RUNNER_TEMP/rt/LinkUnbound.app" - name: Sign it for the updater - if: env.TAURI_SIGNING_PRIVATE_KEY != '' && env.APPLE_ID != '' + if: env.HAS_UPDATER_KEY == 'true' && env.HAS_APPLE_ID == 'true' working-directory: app + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | out="../target/$TARGET/release/bundle/macos" npm run tauri -- signer sign "$out/LinkUnbound.app.tar.gz" @@ -698,12 +716,20 @@ jobs: shell: pwsh run: | $sdk = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin" -Directory | - Where-Object { Test-Path "$($_.FullName)\x64\makeappx.exe" } | + Where-Object { + (Test-Path "$($_.FullName)\x64\makeappx.exe") -and (Test-Path "$($_.FullName)\x64\makepri.exe") + } | Sort-Object Name -Descending | Select-Object -First 1 - if (-not $sdk) { Write-Host "::error::no Windows SDK with makeappx"; exit 1 } + if (-not $sdk) { Write-Host "::error::no Windows SDK with makeappx and makepri"; exit 1 } $v = "${{ needs.version.outputs.version }}" New-Item -ItemType Directory -Force dist | Out-Null + + & "$($sdk.FullName)\x64\makepri.exe" createconfig /cf priconfig.xml /dq en-US_es /o + if ($LASTEXITCODE -ne 0) { Write-Host "::error::makepri would not write a config"; exit 1 } + & "$($sdk.FullName)\x64\makepri.exe" new /pr msix /cf priconfig.xml /of msix/resources.pri /o + if ($LASTEXITCODE -ne 0) { Write-Host "::error::makepri would not index the package"; exit 1 } + & "$($sdk.FullName)\x64\makeappx.exe" pack /d msix /p "dist/linkunbound-$v-windows-x64.msix" /o if ($LASTEXITCODE -ne 0) { Write-Host "::error::makeappx refused the package"; exit 1 } Get-ChildItem dist @@ -924,9 +950,10 @@ jobs: have=$(gh release view "$tag" --json assets -q '.assets[].name' | sort) want=$(ls dist | sort) - if [ "$have" != "$want" ]; then - echo "::error::the release carries a different set of files than dist; it stays a draft" - diff <(echo "$want") <(echo "$have") || true + missing=$(comm -23 <(echo "$want") <(echo "$have")) + if [ -n "$missing" ]; then + echo "::error::the release is missing files this run built; it stays a draft" + echo "$missing" exit 1 fi @@ -1066,7 +1093,7 @@ jobs: echo "the channel still says «${said:-nothing}» (${attempt}/6)" sleep $((attempt * 20)) done - jq . channel.json + if [ -s channel.json ]; then jq . channel.json; fi ahead=$(jq -r .version channel.json 2>/dev/null || echo "") if [ ! -s channel.json ] && [ "${{ needs.version.outputs.prerelease }}" = "true" ]; then @@ -1256,8 +1283,10 @@ jobs: echo "::error::could not download $1" return 1 } - fetch "$DMG_ARM" - fetch "$DMG_INTEL" + if ! fetch "$DMG_ARM" || ! fetch "$DMG_INTEL"; then + echo "::warning::a disk image is missing for ${VERSION}; leaving the cask as it is" + exit 0 + fi ARM_SHA=$(sha256sum "/tmp/$DMG_ARM" | awk '{print $1}') INTEL_SHA=$(sha256sum "/tmp/$DMG_INTEL" | awk '{print $1}') @@ -1269,6 +1298,15 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" mkdir -p Casks + if [ -f "Casks/${CASK}.rb" ]; then + had=$(sed -n 's/^[[:space:]]*version "\(.*\)"/\1/p' "Casks/${CASK}.rb" | head -1) + if [ -n "$had" ] && [ "$had" != "$VERSION" ] \ + && [ "$(printf '%s\n%s\n' "$had" "$VERSION" | sort -V | tail -1)" != "$VERSION" ]; then + echo "::warning::the tap already offers ${had}; writing ${VERSION} would downgrade every Mac that runs brew upgrade" + exit 0 + fi + fi + cat > "Casks/${CASK}.rb" <<-CASK cask "${CASK}" do arch arm: "aarch64", intel: "x86_64" @@ -1330,7 +1368,11 @@ jobs: fi echo "push rejected (attempt ${attempt}/5); rebasing onto the concurrent release" git fetch origin main - git rebase origin/main || { git rebase --abort || true; continue; } + if ! git rebase origin/main; then + git rebase --abort || true + echo "::error::the tap moved under this commit and it will not rebase cleanly" + exit 1 + fi done echo "::error::could not push the tap after 5 attempts" exit 1 diff --git a/.github/workflows/update-badge.yml b/.github/workflows/update-badge.yml index 81d0bbc..a422c4e 100644 --- a/.github/workflows/update-badge.yml +++ b/.github/workflows/update-badge.yml @@ -22,7 +22,7 @@ jobs: persist-credentials: false - name: Setup uv - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: false diff --git a/PRIVACY.md b/PRIVACY.md index 1cba21b..3f1640d 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -98,6 +98,7 @@ All data is stored locally under your user profile. | :------- | :------------------------------------------ | | Browsers | `%LOCALAPPDATA%\LinkUnbound\browsers.json` | | Rules | `%LOCALAPPDATA%\LinkUnbound\rules.json` | +| Settings | `%LOCALAPPDATA%\LinkUnbound\preferences.json` | | Log | `%LOCALAPPDATA%\LinkUnbound\navigate.log` | | Crash log | `%LOCALAPPDATA%\LinkUnbound\startup_crash.log` | | Icons | `%LOCALAPPDATA%\LinkUnbound\icons\` | @@ -108,6 +109,7 @@ All data is stored locally under your user profile. | :------- | :--------------------------------------------------------------- | | Browsers | `~/Library/Application Support/LinkUnbound/browsers.json` | | Rules | `~/Library/Application Support/LinkUnbound/rules.json` | +| Settings | `~/Library/Application Support/LinkUnbound/preferences.json` | | Log | `~/Library/Application Support/LinkUnbound/navigate.log` | | Crash log | `~/Library/Application Support/LinkUnbound/startup_crash.log` | | Icons | `~/Library/Application Support/LinkUnbound/icons/` | @@ -115,6 +117,8 @@ All data is stored locally under your user profile. On macOS, setting LinkUnbound as the default browser records which application held that role before (its bundle identifier, nothing else), so that "stop being the default" can hand the links back to it. +`preferences.json` holds your settings — theme, language, shortcut — and two marks that exist only so the app can ask you for a GitHub star once, at a sensible moment rather than on day one: the date you first opened the settings window, and whether you have already answered. They are read on that screen and nowhere else, they never leave your machine, and deleting the file resets them. + These folders are protected by your operating system's user account permissions. Other users on the same computer cannot access them under normal conditions. --- diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 2104aa5..8b902d9 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -597,16 +597,17 @@ fn spoken(prefs: &Preferences) -> &'static str { #[tauri::command] fn prefs_set(app: AppHandle, prefs: Preferences) -> Result { - let store = store(); - let kept = store.prefs(); - let prefs = Preferences { - here_since: kept.here_since, - asked_for_a_star: kept.asked_for_a_star, - ..prefs - }; - store.save_prefs(&prefs).map_err(|e| e.to_string())?; - shell::repaint(&app, prefs.theme); - Ok(claim(&app, &prefs)) + let mut settled = prefs; + store() + .edit_prefs(|kept| { + settled.here_since = kept.here_since; + settled.asked_for_a_star = kept.asked_for_a_star; + *kept = settled.clone(); + true + }) + .map_err(|e| e.to_string())?; + shell::repaint(&app, settled.theme); + Ok(claim(&app, &settled)) } #[tauri::command] @@ -747,36 +748,48 @@ fn about() -> Build { } } -fn now_in_seconds() -> u64 { +fn now_in_seconds() -> Option { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |since| since.as_secs()) + .ok() + .map(|since| since.as_secs()) } #[tauri::command] fn star_due() -> Result { + let Some(now) = now_in_seconds() else { + return Ok(false); + }; let store = store(); - let mut prefs = store.prefs(); - let now = now_in_seconds(); - let counted = || store.rules().map(|set| set.rules.len()).unwrap_or_default(); - let decided = linkunbound_core::asking(prefs.asked_for_a_star, prefs.here_since, now, counted); - match decided { - Asking::Start => { - prefs.here_since = Some(now); - store.save_prefs(&prefs).map_err(|e| e.to_string())?; - Ok(false) - } - Asking::Wait => Ok(false), - Asking::Now => Ok(true), - } + let mut due = false; + store + .edit_prefs(|prefs| { + let counted = || store.rules().map(|set| set.rules.len()).unwrap_or_default(); + match linkunbound_core::asking(prefs.asked_for_a_star, prefs.here_since, now, counted) { + Asking::Start => { + prefs.here_since = Some(now); + true + } + Asking::Wait => false, + Asking::Now => { + due = true; + false + } + } + }) + .map_err(|e| e.to_string())?; + Ok(due) } #[tauri::command] fn star_done() -> Result<(), String> { - let store = store(); - let mut prefs = store.prefs(); - prefs.asked_for_a_star = true; - store.save_prefs(&prefs).map_err(|e| e.to_string()) + store() + .edit_prefs(|prefs| { + prefs.asked_for_a_star = true; + true + }) + .map(|_| ()) + .map_err(|e| e.to_string()) } #[cfg(windows)] diff --git a/app/src/Settings.tsx b/app/src/Settings.tsx index f17ac55..62ece56 100644 --- a/app/src/Settings.tsx +++ b/app/src/Settings.tsx @@ -312,6 +312,7 @@ function Shell({ onLanguage }: { onLanguage: (next: Language) => void }) { const [state, setState] = useState(null); const [here, setHere] = useState(null); const [problem, setProblem] = useState(null); + const [starring, setStarring] = useState(false); // Choosing the default browser happens in Windows, not here, so the answer to «does Windows // send links our way» changes while this window is in the background. Asked again every time @@ -331,6 +332,10 @@ function Shell({ onLanguage }: { onLanguage: (next: Language) => void }) { .catch(noop); }, []); + useEffect(() => { + void invoke("star_due").then(setStarring).catch(noop); + }, []); + const change = useCallback( (command: string, enabled: boolean) => { setProblem(null); @@ -423,7 +428,16 @@ function Shell({ onLanguage }: { onLanguage: (next: Language) => void }) { /> )} {page === "care" && } - {page === "about" && } + {page === "about" && ( + setStarring(false)} + onSettled={settled} + onLook={look} + /> + )} ); diff --git a/app/src/i18n.ts b/app/src/i18n.ts index 3e318c0..6b06148 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -492,7 +492,7 @@ const EN: Record = { badgeQuiet: "No telemetry", starThanks: "Thank you for using LinkUnbound.", starWhy: "No telemetry, no ads: people only find it when somebody recommends it.", - starGo: "Star on GitHub", + starGo: "Give it a star", starNo: "Don't show again", starLater: "Not now", aboutGitHubSponsor: "Sponsor on GitHub", diff --git a/app/src/settings/About.test.tsx b/app/src/settings/About.test.tsx index 0df0571..3084d71 100644 --- a/app/src/settings/About.test.tsx +++ b/app/src/settings/About.test.tsx @@ -39,12 +39,23 @@ const settled = vi.fn(); /// Stands in for the window that owns the offer, so a look really does move what the screen /// reads instead of the test asserting against a copy only it can see. -function Host({ from, step }: { from: Ready | null; step: Underway | null }) { +function Host({ + from, + step, + starring = false, +}: { + from: Ready | null; + step: Underway | null; + starring?: boolean; +}) { const [ready, setReady] = useState(from); + const [asking, setAsking] = useState(starring); return ( setAsking(false)} onSettled={settled} onLook={(nowPlease) => invoke("update_ready", { nowPlease }).then((one: unknown) => { @@ -60,6 +71,10 @@ function show(ready: Ready | null = null, step: Underway | null = null) { render(); } +function showAsking() { + render(); +} + function answers(overrides: Record Promise> = {}) { invoke.mockImplementation((cmd: string) => { if (cmd in overrides) return overrides[cmd](); @@ -137,6 +152,16 @@ describe("about", () => { expect(opened).toEqual(["ms-windows-store://review/?ProductId=9N9F7C8Q43KC"]); }); + it("asks for the star in the card instead of twice on the same screen", async () => { + showAsking(); + expect(await screen.findByRole("status")).toHaveTextContent("Gracias por usar LinkUnbound"); + expect(screen.queryByRole("button", { name: /Dar una estrella en GitHub/ })).toBeNull(); + + await userEvent.click(screen.getByRole("button", { name: "Ahora no" })); + expect(screen.queryByRole("status")).toBeNull(); + expect(screen.getByRole("button", { name: /Dar una estrella en GitHub/ })).toBeInTheDocument(); + }); + it("says the copy is current when nothing newer was found", async () => { show(); expect(screen.getByText("Estás en la última versión")).toBeInTheDocument(); diff --git a/app/src/settings/About.tsx b/app/src/settings/About.tsx index b6e18d0..d778a4c 100644 --- a/app/src/settings/About.tsx +++ b/app/src/settings/About.tsx @@ -305,11 +305,15 @@ function Candidates({ export default function About({ ready, step, + starring, + onStarSettled, onSettled, onLook, }: { ready: Ready | null; step: Underway | null; + starring: boolean; + onStarSettled: () => void; onSettled: () => void; onLook: (nowPlease?: boolean) => Promise; }) { @@ -318,7 +322,6 @@ export default function About({ const [build, setBuild] = useState(null); const [trouble, setTrouble] = useState(null); const [problem, setProblem] = useState(null); - const [starring, setStarring] = useState(false); const look = useCallback(() => { setTrouble(null); @@ -329,10 +332,6 @@ export default function About({ useEffect(look, [look]); - useEffect(() => { - void invoke("star_due").then(setStarring).catch(noop); - }, []); - return ( <>
@@ -411,24 +410,26 @@ export default function About({ /> )} - {starring && setStarring(false)} />} + {starring && }

{t("aboutSupportWhy")}

- void openUrl(REPO).catch(noop)} - > - - + {!starring && ( + void openUrl(REPO).catch(noop)} + > + + + )} {build?.keptByTheStore && ( void }) { }; return ( -
+