Compare commits

..

No commits in common. "main" and "sync_settings_ui-v0.4.3" have entirely different histories.

704 changed files with 5593 additions and 52298 deletions

View File

@ -1,36 +0,0 @@
name: data_transfer_flutter tests
on:
push:
branches: [main]
paths: ['data_transfer_flutter/**']
pull_request:
branches: [main]
paths: ['data_transfer_flutter/**']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: data_transfer_flutter
steps:
- uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Install dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Run tests with coverage
run: flutter test --coverage
- name: Enforce 100% line coverage
run: scripts/check_coverage.sh coverage/lcov.info 100

View File

@ -1,73 +0,0 @@
name: focus-project pre-commit
on:
push:
branches: [main]
# The workflow's own file counts as focus_project: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['focus_project/**', '.github/workflows/focus-project-precommit.yml']
pull_request:
branches: [main]
paths: ['focus_project/**', '.github/workflows/focus-project-precommit.yml']
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# Only pre-commit itself. Every linting hook runs in its own isolated
# env -- ruff, mypy, pylint, bandit and codespell each come from their
# own repo+rev, and the local hooks need nothing beyond bash and
# python3 -- so installing the full requirements.txt spent 9s a run on
# packages no pre-commit-stage hook imports. Verified by running all
# three configs green in a venv holding pre-commit and nothing else.
# The pin is read out of requirements.txt rather than repeated here, so
# it cannot drift from what the dependency-freshness gate governs.
- name: Install pre-commit
run: |
pip install "$(grep '^pre-commit==' focus_project/requirements.txt)"
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
# package jobs shared one key, raced to overwrite it with hook sets built
# from four different configs, and editing a per-package config did not
# invalidate it. The cache was therefore silently wrong rather than
# missing, and the hooks reinstalled from scratch on every run (38-49s).
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: >-
pre-commit-focus-project-${{ env.pythonLocation }}-${{
hashFiles('focus_project/.pre-commit-config.yaml') }}
# Prefix fallback, or this fix would evaporate on the very
# event that motivated it: bumping a hook rev changes the
# exact key, and a total miss means reinstalling every hook
# again. pre-commit's store is keyed internally by hook
# repo+rev, so a superseded entry is ignored disk rather
# than a wrong hook, and cache still re-saves under the new
# exact key after a prefix-only hit.
restore-keys: pre-commit-focus-project-${{ env.pythonLocation }}-
# mypy re-analyses the stdlib and every third-party stub from scratch
# without this, which measured 10s of the hook step in CI against 0.25s
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
# the config, not the sources: mypy's own cache metadata records the
# interpreter, the flag set and a hash per source file, so it re-checks
# what changed and cannot pass stale code. Deliberately NOT keyed on
# github.sha -- that re-saves 19MB x3 every commit and would evict the
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
# the expensive part, the dependency analysis, which is what costs 10s.
- uses: actions/cache@v4
with:
path: .mypy_cache
key: >-
mypy-focus-project-${{ env.pythonLocation }}-${{
hashFiles('focus_project/.pre-commit-config.yaml') }}
restore-keys: mypy-focus-project-${{ env.pythonLocation }}-
- name: Run the hooks
run: >-
pre-commit run --config focus_project/.pre-commit-config.yaml
--all-files --show-diff-on-failure

View File

@ -1,34 +0,0 @@
name: focus-project tests
on:
push:
branches: [main]
# The workflow's own file counts as focus_project: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['focus_project/**', '.github/workflows/focus-project-tests.yml']
pull_request:
branches: [main]
paths: ['focus_project/**', '.github/workflows/focus-project-tests.yml']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: focus_project
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
cache: pip
cache-dependency-path: focus_project/requirements.txt
- name: Install dependencies
run: pip install -r requirements.txt && pip install --no-deps -e .
- name: Run tests with coverage
run: python -m pytest -v

View File

@ -1,36 +0,0 @@
name: google_account_token tests
on:
push:
branches: [main]
paths: ['google_account_token/**']
pull_request:
branches: [main]
paths: ['google_account_token/**']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: google_account_token
steps:
- uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Install dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Run tests with coverage
run: flutter test --coverage
- name: Enforce 100% line coverage
run: scripts/check_coverage.sh coverage/lcov.info 100

View File

@ -1,73 +0,0 @@
name: holds pre-commit
on:
push:
branches: [main]
# The workflow's own file counts as holds: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['holds/**', '.github/workflows/holds-precommit.yml']
pull_request:
branches: [main]
paths: ['holds/**', '.github/workflows/holds-precommit.yml']
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# Only pre-commit itself. Every linting hook runs in its own isolated
# env -- ruff, mypy, pylint, bandit and codespell each come from their
# own repo+rev, and the local hooks need nothing beyond bash and
# python3 -- so installing the full requirements.txt spent 9s a run on
# packages no pre-commit-stage hook imports. Verified by running all
# three configs green in a venv holding pre-commit and nothing else.
# The pin is read out of requirements.txt rather than repeated here, so
# it cannot drift from what the dependency-freshness gate governs.
- name: Install pre-commit
run: |
pip install "$(grep '^pre-commit==' holds/requirements.txt)"
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
# package jobs shared one key, raced to overwrite it with hook sets built
# from four different configs, and editing a per-package config did not
# invalidate it. The cache was therefore silently wrong rather than
# missing, and the hooks reinstalled from scratch on every run (38-49s).
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: >-
pre-commit-holds-${{ env.pythonLocation }}-${{
hashFiles('holds/.pre-commit-config.yaml') }}
# Prefix fallback, or this fix would evaporate on the very
# event that motivated it: bumping a hook rev changes the
# exact key, and a total miss means reinstalling every hook
# again. pre-commit's store is keyed internally by hook
# repo+rev, so a superseded entry is ignored disk rather
# than a wrong hook, and cache still re-saves under the new
# exact key after a prefix-only hit.
restore-keys: pre-commit-holds-${{ env.pythonLocation }}-
# mypy re-analyses the stdlib and every third-party stub from scratch
# without this, which measured 10s of the hook step in CI against 0.25s
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
# the config, not the sources: mypy's own cache metadata records the
# interpreter, the flag set and a hash per source file, so it re-checks
# what changed and cannot pass stale code. Deliberately NOT keyed on
# github.sha -- that re-saves 19MB x3 every commit and would evict the
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
# the expensive part, the dependency analysis, which is what costs 10s.
- uses: actions/cache@v4
with:
path: .mypy_cache
key: >-
mypy-holds-${{ env.pythonLocation }}-${{
hashFiles('holds/.pre-commit-config.yaml') }}
restore-keys: mypy-holds-${{ env.pythonLocation }}-
- name: Run the hooks
run: >-
pre-commit run --config holds/.pre-commit-config.yaml
--all-files --show-diff-on-failure

View File

@ -1,34 +0,0 @@
name: holds tests
on:
push:
branches: [main]
# The workflow's own file counts as holds: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['holds/**', '.github/workflows/holds-tests.yml']
pull_request:
branches: [main]
paths: ['holds/**', '.github/workflows/holds-tests.yml']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: holds
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
cache: pip
cache-dependency-path: holds/requirements.txt
- name: Install dependencies
run: pip install -r requirements.txt && pip install --no-deps -e .
- name: Run tests with coverage
run: python -m pytest -v

View File

@ -1,73 +0,0 @@
name: leetcode-coach pre-commit
on:
push:
branches: [main]
# The workflow's own file counts as leetcode_coach: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-precommit.yml']
pull_request:
branches: [main]
paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-precommit.yml']
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# Only pre-commit itself. Every linting hook runs in its own isolated
# env -- ruff, mypy, pylint, bandit and codespell each come from their
# own repo+rev, and the local hooks need nothing beyond bash and
# python3 -- so installing the full requirements.txt spent 9s a run on
# packages no pre-commit-stage hook imports. Verified by running all
# three configs green in a venv holding pre-commit and nothing else.
# The pin is read out of requirements.txt rather than repeated here, so
# it cannot drift from what the dependency-freshness gate governs.
- name: Install pre-commit
run: |
pip install "$(grep '^pre-commit==' leetcode_coach/requirements.txt)"
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
# package jobs shared one key, raced to overwrite it with hook sets built
# from four different configs, and editing a per-package config did not
# invalidate it. The cache was therefore silently wrong rather than
# missing, and the hooks reinstalled from scratch on every run (38-49s).
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: >-
pre-commit-leetcode-coach-${{ env.pythonLocation }}-${{
hashFiles('leetcode_coach/.pre-commit-config.yaml') }}
# Prefix fallback, or this fix would evaporate on the very
# event that motivated it: bumping a hook rev changes the
# exact key, and a total miss means reinstalling every hook
# again. pre-commit's store is keyed internally by hook
# repo+rev, so a superseded entry is ignored disk rather
# than a wrong hook, and cache still re-saves under the new
# exact key after a prefix-only hit.
restore-keys: pre-commit-leetcode-coach-${{ env.pythonLocation }}-
# mypy re-analyses the stdlib and every third-party stub from scratch
# without this, which measured 10s of the hook step in CI against 0.25s
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
# the config, not the sources: mypy's own cache metadata records the
# interpreter, the flag set and a hash per source file, so it re-checks
# what changed and cannot pass stale code. Deliberately NOT keyed on
# github.sha -- that re-saves 19MB x3 every commit and would evict the
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
# the expensive part, the dependency analysis, which is what costs 10s.
- uses: actions/cache@v4
with:
path: .mypy_cache
key: >-
mypy-leetcode-coach-${{ env.pythonLocation }}-${{
hashFiles('leetcode_coach/.pre-commit-config.yaml') }}
restore-keys: mypy-leetcode-coach-${{ env.pythonLocation }}-
- name: Run the hooks
run: >-
pre-commit run --config leetcode_coach/.pre-commit-config.yaml
--all-files --show-diff-on-failure

View File

@ -1,34 +0,0 @@
name: leetcode-coach tests
on:
push:
branches: [main]
# The workflow's own file counts as leetcode_coach: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-tests.yml']
pull_request:
branches: [main]
paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-tests.yml']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: leetcode_coach
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
cache: pip
cache-dependency-path: leetcode_coach/requirements.txt
- name: Install dependencies
run: pip install -r requirements.txt && pip install --no-deps -e .
- name: Run tests with coverage
run: python -m pytest -v

View File

@ -1,6 +1,6 @@
name: md-naming
# The markdown namespaces (README / CLAUDE* / DOCS*, no TODO*), enforced on push as well as at commit time.
# The four markdown namespaces, enforced on push as well as at commit time.
# The local pre-commit hook can be skipped (a fresh clone that never ran
# `pre-commit install` has no hooks at all), so the convention needs a second
# gate that no one can forget to install.
@ -31,3 +31,8 @@ jobs:
- name: Enforce the naming convention
run: bash .utils/scripts/check_md_naming.sh --all
# Warns only: no machine can prove a task is finished, so an old TODO
# gets a nudge, never a failure.
- name: Warn about stale TODOs
run: PYTHONPATH=.utils python3 .utils/md_naming/stale.py

View File

@ -28,4 +28,4 @@ jobs:
run: pip install -r requirements.txt
- name: Run tests with coverage
run: python -m pytest -v --cov=music_theory
run: python -m pytest -v

View File

@ -1,73 +0,0 @@
name: omniroute_offload pre-commit
on:
push:
branches: [main]
# The workflow's own file counts as omniroute_offload: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['omniroute_offload/**', '.github/workflows/omniroute_offload-precommit.yml']
pull_request:
branches: [main]
paths: ['omniroute_offload/**', '.github/workflows/omniroute_offload-precommit.yml']
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# Only pre-commit itself. Every linting hook runs in its own isolated
# env -- ruff, mypy, pylint, bandit and codespell each come from their
# own repo+rev, and the local hooks need nothing beyond bash and
# python3 -- so installing the full requirements.txt spent 9s a run on
# packages no pre-commit-stage hook imports. Verified by running all
# three configs green in a venv holding pre-commit and nothing else.
# The pin is read out of requirements.txt rather than repeated here, so
# it cannot drift from what the dependency-freshness gate governs.
- name: Install pre-commit
run: |
pip install "$(grep '^pre-commit==' omniroute_offload/requirements.txt)"
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
# package jobs shared one key, raced to overwrite it with hook sets built
# from four different configs, and editing a per-package config did not
# invalidate it. The cache was therefore silently wrong rather than
# missing, and the hooks reinstalled from scratch on every run (38-49s).
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: >-
pre-commit-omniroute_offload-${{ env.pythonLocation }}-${{
hashFiles('omniroute_offload/.pre-commit-config.yaml') }}
# Prefix fallback, or this fix would evaporate on the very
# event that motivated it: bumping a hook rev changes the
# exact key, and a total miss means reinstalling every hook
# again. pre-commit's store is keyed internally by hook
# repo+rev, so a superseded entry is ignored disk rather
# than a wrong hook, and cache still re-saves under the new
# exact key after a prefix-only hit.
restore-keys: pre-commit-omniroute_offload-${{ env.pythonLocation }}-
# mypy re-analyses the stdlib and every third-party stub from scratch
# without this, which measured 10s of the hook step in CI against 0.25s
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
# the config, not the sources: mypy's own cache metadata records the
# interpreter, the flag set and a hash per source file, so it re-checks
# what changed and cannot pass stale code. Deliberately NOT keyed on
# github.sha -- that re-saves 19MB x3 every commit and would evict the
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
# the expensive part, the dependency analysis, which is what costs 10s.
- uses: actions/cache@v4
with:
path: .mypy_cache
key: >-
mypy-omniroute_offload-${{ env.pythonLocation }}-${{
hashFiles('omniroute_offload/.pre-commit-config.yaml') }}
restore-keys: mypy-omniroute_offload-${{ env.pythonLocation }}-
- name: Run the hooks
run: >-
pre-commit run --config omniroute_offload/.pre-commit-config.yaml
--all-files --show-diff-on-failure

View File

@ -31,8 +31,3 @@ jobs:
- name: Categorical ramp still passes its constraints
run: python3 unified-design-system/scripts/ramp_check.py
# Spacing, radius, type, shadow and motion: palette_check.py parses
# hex only, so these scales were unguarded until structural_check.py.
- name: Structural scales agree across all token sources
run: python3 unified-design-system/scripts/structural_check.py

View File

@ -1,73 +0,0 @@
name: sql-drills pre-commit
on:
push:
branches: [main]
# The workflow's own file counts as sql_drills: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['sql_drills/**', '.github/workflows/sql-drills-precommit.yml']
pull_request:
branches: [main]
paths: ['sql_drills/**', '.github/workflows/sql-drills-precommit.yml']
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# Only pre-commit itself. Every linting hook runs in its own isolated
# env -- ruff, mypy, pylint, bandit and codespell each come from their
# own repo+rev, and the local hooks need nothing beyond bash and
# python3 -- so installing the full requirements.txt spent 9s a run on
# packages no pre-commit-stage hook imports. Verified by running all
# three configs green in a venv holding pre-commit and nothing else.
# The pin is read out of requirements.txt rather than repeated here, so
# it cannot drift from what the dependency-freshness gate governs.
- name: Install pre-commit
run: |
pip install "$(grep '^pre-commit==' sql_drills/requirements.txt)"
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
# package jobs shared one key, raced to overwrite it with hook sets built
# from four different configs, and editing a per-package config did not
# invalidate it. The cache was therefore silently wrong rather than
# missing, and the hooks reinstalled from scratch on every run (38-49s).
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: >-
pre-commit-sql-drills-${{ env.pythonLocation }}-${{
hashFiles('sql_drills/.pre-commit-config.yaml') }}
# Prefix fallback, or this fix would evaporate on the very
# event that motivated it: bumping a hook rev changes the
# exact key, and a total miss means reinstalling every hook
# again. pre-commit's store is keyed internally by hook
# repo+rev, so a superseded entry is ignored disk rather
# than a wrong hook, and cache still re-saves under the new
# exact key after a prefix-only hit.
restore-keys: pre-commit-sql-drills-${{ env.pythonLocation }}-
# mypy re-analyses the stdlib and every third-party stub from scratch
# without this, which measured 10s of the hook step in CI against 0.25s
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
# the config, not the sources: mypy's own cache metadata records the
# interpreter, the flag set and a hash per source file, so it re-checks
# what changed and cannot pass stale code. Deliberately NOT keyed on
# github.sha -- that re-saves 19MB x3 every commit and would evict the
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
# the expensive part, the dependency analysis, which is what costs 10s.
- uses: actions/cache@v4
with:
path: .mypy_cache
key: >-
mypy-sql-drills-${{ env.pythonLocation }}-${{
hashFiles('sql_drills/.pre-commit-config.yaml') }}
restore-keys: mypy-sql-drills-${{ env.pythonLocation }}-
- name: Run the hooks
run: >-
pre-commit run --config sql_drills/.pre-commit-config.yaml
--all-files --show-diff-on-failure

View File

@ -1,34 +0,0 @@
name: sql-drills tests
on:
push:
branches: [main]
# The workflow's own file counts as sql_drills: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['sql_drills/**', '.github/workflows/sql-drills-tests.yml']
pull_request:
branches: [main]
paths: ['sql_drills/**', '.github/workflows/sql-drills-tests.yml']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: sql_drills
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
cache: pip
cache-dependency-path: sql_drills/requirements.txt
- name: Install dependencies
run: pip install -r requirements.txt && pip install --no-deps -e .
- name: Run tests with coverage
run: python -m pytest -v

View File

@ -13,7 +13,7 @@
# linters, and carry this same hook so committing with one of those configs is
# gated too. This root config is what `pre-commit install` wires up, and it is
# the only one that covers the subprojects without a config of their own
# (guard-lib/, crdt_sync_dart/, file_length/, ...).
# (aur-publish/, guard-lib/, crdt_sync_dart/, file_length/, ...).
#
# The path is repo-relative on purpose. pre-commit always runs hooks from the
# git root, so this resolves the same locally and on a CI runner -- an absolute
@ -27,8 +27,8 @@ repos:
language: system
pass_filenames: true
# Three markdown namespaces (README / CLAUDE* / DOCS*); TODO*.md is banned
# (tasks live in the todo app).
# Four markdown namespaces (README / CLAUDE* / DOCS* / TODO*), plus the
# rule that a TODO carries "REMOVE ME AFTER FINISH" and nothing else may.
# Shared gate in ~/src/utils owns the namespaces and the exemption list, so
# one repo's idea of a valid name cannot drift from every other repo's.
# Since the 2026-09-11 reorganisation ~ holds eleven visible entries and
@ -66,14 +66,9 @@ repos:
# answer and passes with a loud banner; `--no-verify` is banned here, so
# a hook that hard-failed on a flaky connection would leave no way to
# commit at all. CI, always online, is the strict authority.
#
# Scoped to manifests this branch changed (vs merge-base with main), so
# `--all-files` asks "did THIS change leave a stale pin?" and not "is
# every project in the monorepo current?". CI `--all --strict` keeps
# the repo-wide answer. See scripts/dep_freshness_changed.sh.
- id: dependency-freshness
name: dependencies on newest stable
entry: bash scripts/dep_freshness_changed.sh
entry: bash scripts/check_dependency_freshness.sh
language: system
pass_filenames: true
files: >-

View File

@ -1,27 +0,0 @@
# Hooks other repos consume with `repo: https://github.com/kuhyx/utils`.
# All three are post-* stages, so each clone must install them (see
# todo_hooks/README.md). They never fail a git operation: the work has
# already happened, so server/gh trouble prints one warning and exits 0.
- id: todo-closer
name: close Todo trailer tasks
entry: scripts/todo_hooks.sh closer
language: script
stages: [post-commit]
always_run: true
pass_filenames: false
- id: todo-import-pr-merge
name: import PR Todo sections (pull)
entry: scripts/todo_hooks.sh import-pr post-merge
language: script
stages: [post-merge]
always_run: true
pass_filenames: false
- id: todo-import-pr-rewrite
name: import PR Todo sections (pull --rebase)
entry: scripts/todo_hooks.sh import-pr post-rewrite
language: script
stages: [post-rewrite]
always_run: true
pass_filenames: false

View File

@ -11,8 +11,8 @@ overwrite it with `repo_contract/templates/check_repo_contract.shim.sh`.
## Commands
- run: n/a: collection of gates and libraries, no single entry point
- test: `PYTHONPATH=. python -m pytest -q agent_ready/tests coverage_gaps/tests dep_freshness/tests file_length/tests flutter_web_drive/tests home_paths/tests md_naming/tests repo_contract/tests scripts/tests unified-design-system/tests`
- test: `PYTHONPATH=. python -m pytest -q agent_ready/tests coverage_gaps/tests dep_freshness/tests file_length/tests flutter_web_drive/tests home_paths/tests md_naming/tests repo_contract/tests scripts/tests`
- test-changed: `scripts/test_changed.sh`
- lint: `ruff check agent_ready coverage_gaps flutter_web_drive repo_contract`
- coverage: `PYTHONPATH=. python -m pytest -q agent_ready/tests flutter_web_drive/tests repo_contract/tests unified-design-system/tests --cov=agent_ready --cov=flutter_web_drive --cov=repo_contract --cov=conformance --cov=conformance_diff --cov-branch --cov-report=xml --cov-fail-under=100`
- coverage: `PYTHONPATH=. python -m pytest -q agent_ready/tests flutter_web_drive/tests repo_contract/tests --cov=agent_ready --cov=flutter_web_drive --cov=repo_contract --cov-branch --cov-report=xml --cov-fail-under=100`
- coverage-gaps: `coverage-gaps coverage.xml`

View File

@ -42,21 +42,6 @@ dependencies:
path: sync_settings_ui
```
## google_account_token/
Flutter plugin (Android only; other platforms return null): one-tap Google ID
token via `AccountManager`, replacing `google_sign_in` in the sync apps.
Consume via:
```yaml
dependencies:
google_account_token:
git:
url: https://github.com/kuhyx/utils
ref: google_account_token-v0.1.0
path: google_account_token
```
## crdt-sync/
Python CRDT sync library (package: `crdt_sync`, not published to PyPI). Same

View File

@ -0,0 +1,153 @@
# TODO — pylint `--fail-under=10` everywhere, and clear what that surfaces
REMOVE ME AFTER FINISH
## what
Ten pylint hooks across six repos still pass at `--fail-under=8.0`, which
permanently tolerates two points of lint debt and lets a score *regress* from
9.9 to 8.1 without failing anything. Move every one to `10`, and fix what
that surfaces. Five other sites already run at `10`, so this is adopting an
existing standard, not inventing one.
## the ten sites, exact
Measured 2026-09-12. "Score now" is that package's current rating under its
own config, tests included.
| Repo / package | Site | Score now |
|---|---|---|
| `control-panel` | `.pre-commit-config.yaml:159` | 8.53 |
| `leetcode-guard` | `.pre-commit-config.yaml:112` | 8.71 |
| `screen-locker` | `.pre-commit-config.yaml:121` | 9.85 |
| `screen-locker` | `.pre-commit-config.yaml:140` | (2nd pylint hook) |
| `steam-backlog-enforcer` | `.pre-commit-config.yaml:124` | 8.76 |
| `testsAndMisc/meta` | `.pre-commit-config.yaml:194` | 8.77 |
| `utils/crdt-sync` | `.pre-commit-config.yaml:111` | 9.59 |
| `utils/freedays` | `.pre-commit-config.yaml:109` | 8.55 |
| `utils/gatelock` | `.pre-commit-config.yaml:109` | 9.18 |
| `utils/music_theory` | `.pre-commit-config.yaml:113` | 8.54 |
`screen-locker` has **two** pylint hooks; both need the bump or the second one
silently keeps the old bar.
Already at `10` — do not touch, these are the reference:
`build-your-x`, `diet-guard`, `testsAndMisc` (root config),
`wake-alarm`, `WUT_Computer_Science/.../translator/.pylintrc`.
## why the scores look bad, and why that is misleading
Every one of these configs runs `enable = "all"` with `disable = []` — the
strictest pylint setting there is, including checks that are off by default.
The raw findings are dominated by **test-file idioms**, not source defects.
Measured on the four `utils` packages:
```
crdt-sync 54 protected-access, 50 redefined-outer-name, 18 duplicate-code
freedays 100 missing-function-docstring, 14 implicit-booleaness
gatelock 147 redefined-outer-name, 142 protected-access
music_theory 112 missing-function-docstring, 14 missing-module-docstring
```
`redefined-outer-name` at that volume is pytest fixtures. `protected-access`
is tests reaching into internals on purpose. Neither is a defect.
**The repos already at 10 solved this and wrote down why.** From
`wake-alarm/pyproject.toml`, verbatim:
> `ignore = [".venv", "__pycache__", "tests"]` — "tests" is a basename match:
> test suites intentionally use patterns (protected-access, magic-value
> comparisons) that don't apply to source code, matching testsAndMisc's
> pylint scope (tests are linted by ruff, not pylint there either).
So the fleet standard is: **pylint grades source, ruff grades tests.** The ten
sites above are the outliers that never adopted it. Their `ruff` config
already carries the equivalent test exemptions
(`"**/tests/**/*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]`),
so tests stay linted — by the tool that has the right rules for them.
## what is actually left after adopting the fleet config
Measured: `pylint --ignore=.venv,__pycache__,tests` plus wake-alarm's
three-item `disable` list, run over source files only.
| Package | Score | Remaining findings |
|---|---|---|
| `crdt-sync` | 9.93 | 5 wrong-import-position, 3 duplicate-code, 1 too-many-locals, 1 invalid-name |
| `freedays` | 9.63 | 3 import-error, 1 broad-exception-caught |
| `gatelock` | 9.88 | 6 missing-function-docstring, 4 wrong-import-position, 3 too-many-instance-attributes, 3 import-outside-toplevel, 2 broad-exception-caught, 1 each too-many-arguments / invalid-name / duplicate-code / disallowed-name / chained-comparison |
| `music_theory` | 9.42 | 10 import-error |
That is ~47 real findings across four packages, not the ~600 the raw run
suggests. **`import-error` is almost certainly config, not code**: every repo
already at 10 carries an `init-hook` the 8.0 repos lack —
```toml
init-hook = "import sys; sys.path.insert(0, '.')"
```
Try that first on `music_theory` and `freedays` before editing any import.
## must
- Every site at `--fail-under=10`. No `9.5`, no per-repo exception.
- Adopt the reference config from `wake-alarm` / `diet-guard`: the `tests`
basename ignore, the `init-hook`, and only then the residue fixes.
- Fix findings in source. Prefer the fix to the disable, every time.
- must not: add a `# pylint: disable=` comment, or extend `disable = [...]`
beyond wake-alarm's three implicit-booleaness entries, **without asking
kuhy first — every time, per `memories/code-quality.md`.** All seven
"unavoidable" disables in a past konbini-67 session were reverted.
- must not: exclude a source file from pylint to raise the score, or drop
`enable = "all"`.
- must not: touch the five sites already at `10`.
- optional: `duplicate-code` findings may be genuine shared-code candidates
for `utils`. Note them, do not force a refactor to hit the number.
## done
Both commands, from `~`:
```bash
# 1. no site left below 10 (cov-fail-under is coverage, not pylint)
! grep -rn --include=.pre-commit-config.yaml --include=.pylintrc \
-E 'fail-under=(8|9)' ~/src ~/praca_magisterska 2>/dev/null \
| grep -v cov-fail-under
# 2. every one of the nine packages passes its own pylint hook
for d in ~/src/control-panel ~/src/leetcode-guard ~/src/screen-locker \
~/src/steam-backlog-enforcer ~/src/testsAndMisc/meta \
~/src/utils/crdt-sync ~/src/utils/freedays \
~/src/utils/gatelock ~/src/utils/music_theory; do
(cd "$d" && pre-commit run pylint --all-files) || echo "FAIL $d"
done
```
Command 1 prints nothing and exits 0; command 2 prints no `FAIL` line.
## verify
Desktop. Per repo, `pre-commit run --all-files` must be green — not just the
pylint hook, since a fix that satisfies pylint can break `ruff`, `mypy` or the
250-line cap. Then the test suite: these are behaviour-affecting edits
(`broad-exception-caught` and `import-outside-toplevel` fixes especially), and
every one of these packages holds a 100% coverage bar.
`utils` and `testsAndMisc` each have CI; push and confirm green rather than
trusting the local run. `gatelock`'s Tk suite is flaky on a live X session —
run it under `xvfb-run -a` and compare against a baseline worktree before
blaming a change for a failure.
## read first
- `~/src/wake-alarm/pyproject.toml` — `[tool.pylint]`, the reference config.
`diet-guard` is byte-similar; `build-your-x` also ignores `conftest.py`.
- `~/.claude/memories/code-quality.md` — the suppression rule that governs
every `disable` decision in this task.
- `~/src/utils/rules/` is not involved; this is not a dependency change.
Each repo is its own git repo: commit per repo, work on `main`, stage
narrowly. `utils` additionally runs the shared `md-naming` and file-length
gates, so delete this file in that repo's final commit.
REMOVE ME AFTER FINISH

45
aur-publish/README.md Normal file
View File

@ -0,0 +1,45 @@
# aur-publish
Hands-off publishing of kuhy's Flutter apps to the AUR.
```bash
~/src/utils/aur-publish/publish.sh # wait for registration, then publish
~/src/utils/aur-publish/publish.sh --dry-run # everything except the push
~/src/utils/aur-publish/publish.sh --skip-wait # account already works
~/src/utils/aur-publish/publish.sh --only habit-stack
```
## What it does
1. Polls `/register` until AUR signup reopens, opens it in a browser and
prints the SSH key to paste. **Signup is the only manual step** — it
requires email confirmation.
2. Waits for `ssh aur@aur.archlinux.org` to authenticate.
3. Then, unattended, per package: resolve the newest `vX.Y.Z` tag → set
`pkgver` → `updpkgsums` → `makepkg -C` → `namcap` → AUR rule check →
`pacman -U` → launch check → `.SRCINFO` → commit → push.
Every package is built, installed and **launched** before it is pushed, so a
broken package cannot reach the AUR. A package failing any gate is skipped and
reported; the others still publish. Safe to re-run.
## anubis_gate.py
The AUR sits behind [Anubis](https://anubis.techaro.lol/), which answers HTML
requests with a proof-of-work challenge page carrying **HTTP 200**. A plain
`curl` therefore reports 200 for a page that is actually unreachable — which is
exactly how "is registration back?" gets answered wrong. This module solves the
challenge and reports the status of the *real* page behind it.
```bash
python3 anubis_gate.py /register # prints the true status code
```
## AUR rules enforced before pushing
The AUR does not review uploads, but per the submission guidelines "packages
that violate the rules may be deleted without warning". `check_aur_rules()`
blocks a push on the mechanically checkable ones: `# Maintainer:` line,
`x86_64` in `arch`, no `replaces=` (reserved for renames — use `conflicts`),
not already in an official repo, `pkgdesc`/`url`/`license` present, and
`.SRCINFO` matching the PKGBUILD.

138
aur-publish/anubis_gate.py Normal file
View File

@ -0,0 +1,138 @@
"""Fetch an AUR URL from behind the Anubis anti-bot gate.
The AUR sits behind Anubis (https://anubis.techaro.lol/), which answers every
HTML request with a proof-of-work challenge page carrying HTTP **200**. A bare
`curl` therefore reports 200 for a page that is actually unreachable, which is
exactly how "is /register back up?" gets answered wrong.
This module solves the challenge and reports the status of the *real* page
behind it, so the caller can distinguish:
* 200 -> genuinely available
* 503 -> reached the site; that endpoint is disabled server-side
* other-> whatever the app actually said
Exit codes mirror the HTTP status so shell callers can branch on them:
0 for 200, 1 for anything else, 2 for a local/transport failure.
"""
from __future__ import annotations
import argparse
import hashlib
import http.cookiejar
import json
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BASE = "https://aur.archlinux.org"
UA = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
CHALLENGE_RE = re.compile(
r'<script id="anubis_challenge" type="application/json">(.*?)</script>',
re.S,
)
PASS_PATH = "/.within.website/x/cmd/anubis/api/pass-challenge"
# A solve at difficulty 4 takes ~0.01s; this ceiling only guards a server-side
# difficulty spike, so the poller degrades instead of spinning forever.
MAX_NONCE = 50_000_000
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""Keep redirects visible instead of silently following them."""
def redirect_request(self, *_args, **_kwargs): # noqa: D102 - see class doc
return None
def _opener() -> urllib.request.OpenerDirector:
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(jar), _NoRedirect
)
opener.addheaders = [
("User-Agent", UA),
("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"),
]
return opener
def _solve(random_data: str, difficulty: int) -> tuple[int, str, float]:
"""Find a nonce whose sha256 digest starts with `difficulty` zero hex chars."""
prefix = "0" * difficulty
started = time.time()
for nonce in range(MAX_NONCE):
digest = hashlib.sha256(f"{random_data}{nonce}".encode()).hexdigest()
if digest.startswith(prefix):
return nonce, digest, time.time() - started
raise RuntimeError(f"no nonce found below {MAX_NONCE} at difficulty {difficulty}")
def fetch(path: str, timeout: int = 30) -> tuple[int, str]:
"""Return (status, body) for `path`, clearing Anubis first if challenged."""
opener = _opener()
url = f"{BASE}{path}"
try:
with opener.open(url, timeout=timeout) as resp:
body = resp.read().decode("utf-8", "replace")
status = resp.status
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", "replace")
match = CHALLENGE_RE.search(body)
if not match:
return status, body
challenge = json.loads(match.group(1))["challenge"]
nonce, digest, elapsed = _solve(
challenge["randomData"], int(challenge["difficulty"])
)
query = urllib.parse.urlencode(
{
"id": challenge["id"],
"response": digest,
"nonce": str(nonce),
"redir": path,
"elapsedTime": str(int(elapsed * 1000)),
}
)
try:
opener.open(f"{BASE}{PASS_PATH}?{query}", timeout=timeout)
except urllib.error.HTTPError:
# A redirect or non-2xx here is fine; what matters is the cookie the
# opener's jar just captured.
pass
try:
with opener.open(url, timeout=timeout) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", "replace")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("path", help="AUR path, e.g. /register")
parser.add_argument(
"--print-body", action="store_true", help="write the page body to stdout"
)
args = parser.parse_args()
try:
status, body = fetch(args.path)
except (urllib.error.URLError, RuntimeError, TimeoutError) as exc:
print(f"transport error: {exc}", file=sys.stderr)
return 2
print(status)
if args.print_body:
sys.stdout.write(body)
return 0 if status == 200 else 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,107 @@
#!/bin/bash
# ============================================================================
# Building, verifying and pushing one package to the AUR.
#
# Sourced by publish.sh, never executed: these functions read the readonly
# config (AUR_HOST, AUR_ROOT, DRY_RUN, PUBLISHED, FAILED) that the entry script declares, and
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
# ============================================================================
set -euo pipefail
publish_one() {
local pkg="$1" repo="$2" wrapper="$3" binary="$4" expected="$5"
local dir="$AUR_ROOT/$pkg"
local version
log "$pkg"
[[ -d "$repo" ]] || { warn "$pkg: no repo at $repo"; return 1; }
[[ -f "$dir/PKGBUILD" ]] || { warn "$pkg: no PKGBUILD at $dir"; return 1; }
version="$(newest_tag "$repo")"
[[ -n "$version" ]] || { warn "$pkg: no vX.Y.Z tag on the remote"; return 1; }
info "newest release tag: v$version"
cd "$dir"
sed -i -E "s/^pkgver=.*/pkgver=$version/" PKGBUILD
# The tarball for a tag contains the PKGBUILD, so the checksum can only be
# computed after the tag exists — never committed ahead of time.
updpkgsums >/dev/null 2>&1 || { warn "$pkg: updpkgsums failed (tag missing?)"; return 1; }
info "building"
if ! env PATH=/usr/bin:/bin:/usr/local/bin makepkg -Cf --noconfirm >/dev/null 2>&1; then
warn "$pkg: makepkg failed"
return 1
fi
local built
built="$(find . -maxdepth 1 -name "$pkg-$version-*.pkg.tar.zst" | head -1)"
[[ -n "$built" ]] || { warn "$pkg: no package produced"; return 1; }
# namcap's "ELF files outside of a valid path ('opt/')" is a false positive
# for /opt-installed apps, so only genuinely new errors are worth failing on.
local findings
findings="$(namcap "$built" 2>&1 | grep -E ' E: ' | grep -v "outside of a valid path" || true)"
if [[ -n "$findings" ]]; then
warn "$pkg: namcap errors:"
printf ' %s\n' "$findings"
return 1
fi
info "installing and launching"
sudo pacman -U --noconfirm "$built" >/dev/null 2>&1 \
|| { warn "$pkg: pacman -U failed"; return 1; }
verify_installed "$binary" "$expected" "$wrapper" || { warn "$pkg: launch check failed"; return 1; }
# Generated before the rule check, which verifies it matches the PKGBUILD.
makepkg --printsrcinfo > .SRCINFO
check_aur_rules "$pkg" || return 1
# The AUR repo is a separate remote living alongside the build dir; adding
# it here (rather than cloning into a fresh path) keeps the verified
# PKGBUILD and the thing being pushed byte-identical.
if [[ ! -d .git ]]; then
git init -q -b master .
git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
fi
git remote get-url origin >/dev/null 2>&1 \
|| git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
# Fetch first: an existing AUR package must be built on, not overwritten.
if git fetch -q origin master 2>/dev/null; then
git reset -q --soft FETCH_HEAD 2>/dev/null || true
fi
cat > .gitignore <<'EOF'
# Build products; the AUR tracks only the recipe.
*.pkg.tar.zst
*.tar.gz
src/
pkg/
EOF
git add PKGBUILD .SRCINFO .gitignore
if git diff --cached --quiet; then
info "already up to date on the AUR; nothing to push"
PUBLISHED+=("$pkg v$version (unchanged)")
return 0
fi
git -c user.name='Krzysztof Rudnicki' \
-c user.email='krzysztofrudnicki0@gmail.com' \
commit -q -m "$pkg $version-1"
if ((DRY_RUN)); then
info "DRY RUN — not pushing"
PUBLISHED+=("$pkg v$version (dry run)")
return 0
fi
info "pushing to the AUR"
git push -q origin master 2>&1 | tail -3 || { warn "$pkg: push failed"; return 1; }
PUBLISHED+=("$pkg v$version")
return 0
}

View File

@ -0,0 +1,107 @@
#!/bin/bash
# ============================================================================
# Waiting for AUR registration and SSH access to come up.
#
# Sourced by publish.sh, never executed: these functions read the readonly
# config (AUR_HOST, SSH_KEY, GATE, POLL_SECONDS) that the entry script declares, and
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
# ============================================================================
set -euo pipefail
register_status() {
python3 "$GATE" /register 2>/dev/null | head -1
}
open_browser() {
local url="$1"
local browser
for browser in xdg-open thorium-browser chromium google-chrome-stable firefox librewolf; do
if command -v "$browser" >/dev/null 2>&1; then
"$browser" "$url" >/dev/null 2>&1 &
info "Opened $url in $browser"
return 0
fi
done
warn "No browser found; open this yourself: $url"
}
ensure_key() {
if [[ ! -f "$SSH_KEY" ]]; then
log "Generating an AUR SSH key (none at $SSH_KEY)"
ssh-keygen -t ed25519 -f "$SSH_KEY" -N '' -C 'kuhy@aur'
fi
if ! grep -q "Host $AUR_HOST" "$HOME/.ssh/config" 2>/dev/null; then
log "Adding $AUR_HOST to ~/.ssh/config"
mkdir -p "$HOME/.ssh"
cat >> "$HOME/.ssh/config" <<EOF
# AUR uses a dedicated key so it stays separate from the GitHub identity.
Host $AUR_HOST
User aur
IdentityFile $SSH_KEY
IdentitiesOnly yes
EOF
chmod 600 "$HOME/.ssh/config"
fi
}
ssh_works() {
# The AUR refuses interactive shells; "Interactive shell is disabled" on a
# successful key auth is the success signal, not an error.
ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
-o ConnectTimeout=15 "aur@$AUR_HOST" help 2>&1 |
grep -qiE 'interactive shell is disabled|Welcome to AUR'
}
wait_for_registration() {
local status
status="$(register_status)"
if [[ "$status" == "200" ]]; then
log "AUR registration is OPEN"
else
log "AUR registration is closed (HTTP $status). Polling every ${POLL_SECONDS}s."
info "Ctrl-C to stop; re-run any time, nothing is lost."
while :; do
sleep "$POLL_SECONDS"
status="$(register_status)"
if [[ "$status" == "200" ]]; then
log "Registration is BACK (HTTP 200)"
break
fi
printf ' %s still HTTP %s\n' "$(date +%H:%M)" "$status"
done
fi
open_browser "https://$AUR_HOST/register"
cat <<EOF
------------------------------------------------------------------
Sign up, and paste this into the "SSH Public Key" field:
$(sed 's/^/ /' "$SSH_KEY.pub")
You will need to confirm the address from your email before the
account works. This script waits for that automatically.
------------------------------------------------------------------
EOF
}
wait_for_ssh() {
log "Waiting for the SSH key to authenticate against the AUR"
local waited=0
until ssh_works; do
sleep 30
waited=$((waited + 30))
if ((waited % 300 == 0)); then
info "still waiting ($((waited / 60)) min) — key not accepted yet"
fi
done
log "SSH authentication OK"
}
# ---------------------------------------------------------------------------
# Phase 2: build, verify and publish one package
# ---------------------------------------------------------------------------

View File

@ -0,0 +1,122 @@
#!/bin/bash
# ============================================================================
# Gates a package must pass before it is allowed near the AUR.
#
# Sourced by publish.sh, never executed: these functions read the readonly
# config (AUR_ROOT, DRY_RUN) that the entry script declares, and
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
# ============================================================================
set -euo pipefail
newest_tag() {
# Newest clean vX.Y.Z on the remote. Build tags (vX.Y.Z-build.N) and the
# grafted upstream Flutter SDK tags in diet-guard are excluded on purpose.
git -C "$1" ls-remote --tags origin 2>/dev/null |
awk '{print $2}' |
sed 's|refs/tags/||' |
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
sed 's/^v//' |
sort -V |
tail -1
}
verify_installed() {
local binary="$1" expected="$2" wrapper="$3" output
# An instance left over from a previous run would make the wrapper print
# "already running" and hand off, which passes trivially and turns the
# strongest gate in this script into the weakest. Clear it first so the
# check always exercises a real cold start.
#
# Resolved through /proc/<pid>/exe rather than any form of name or
# command-line matching. Every argv-based approach selects the wrong
# processes here:
# * `pkill -x` compares against the kernel's 15-character comm field, and
# two of these wrappers are longer (habit_stack_desktop is 19), so a
# name match would silently never fire.
# * `pkill -f`, and equally `ps -eo args` piped through a pattern, match
# any process whose command line merely *mentions* the path — this
# script, the shell running it, and the matching tool itself all
# qualify, so the run kills its own shell instead of the app. A
# $$/$PPID guard does not cover it, because the offending shell is
# often a more distant ancestor.
# /proc/<pid>/exe is a kernel-maintained symlink to the binary actually
# being executed, so it cannot collide with an incidental mention.
# The wrapper name is unique per app, so the /opt/<pkg>/ segment is left as
# a glob instead of threading the package name through another parameter.
#
# The link is read without `-f`: it is already fully resolved, and by the
# time this runs `pacman -U` has replaced the binary, so a leftover process
# points at an unlinked inode that the kernel reports with a " (deleted)"
# suffix. That suffix has to be stripped or the match silently misses the
# one process this exists to kill.
local pid exe killed=()
for pid in /proc/[0-9]*; do
pid="${pid#/proc/}"
exe="$(readlink "/proc/$pid/exe" 2>/dev/null)" || continue
exe="${exe% (deleted)}"
[[ "$exe" == /opt/*/bin/"$wrapper" ]] || continue
kill -TERM "$pid" 2>/dev/null && killed+=("$pid")
done
if ((${#killed[@]})); then
info "stopped a running instance so the launch check is meaningful"
# `kill` only reports that the signal was sent. Waiting for the process
# to actually go keeps a slow exit from failing the cold start below on
# a port that is still bound.
local waited=0
while ((waited < 100)) && kill -0 "${killed[@]}" 2>/dev/null; do
sleep 0.1
((waited++))
done
fi
output="$(timeout 15 "/usr/bin/$binary" 2>&1 | head -3 || true)"
if grep -qF "$expected" <<<"$output"; then
return 0
fi
warn "unexpected startup output: $output"
return 1
}
# The AUR does not review uploads, but "packages that violate the rules may be
# deleted without warning" (AUR submission guidelines). These are the rules that
# are mechanically checkable, enforced here so a violation blocks the push
# rather than surfacing later as a deletion request.
check_aur_rules() {
local pkg="$1" problems=()
grep -q '^# Maintainer:' PKGBUILD \
|| problems+=("missing '# Maintainer:' comment on line 1")
grep -qE "^arch=\(.*x86_64" PKGBUILD \
|| problems+=("x86_64 not in arch=() — packages without it are not allowed")
# `replaces` makes pacman rip out the named package on the next -Sy, which
# is only correct for a genuine rename. `conflicts` is the right tool.
grep -q '^replaces=' PKGBUILD \
&& problems+=("uses replaces=; AUR rules reserve it for renames — use conflicts=")
# A package already in an official repo must never be duplicated.
if pacman -Si "$pkg" >/dev/null 2>&1; then
problems+=("$pkg exists in an official repo — must not be submitted")
fi
grep -qE '^(pkgdesc|url|license)=' PKGBUILD \
|| problems+=("missing pkgdesc/url/license")
# A stale .SRCINFO is the single most common upload rejection.
if ! diff -q <(makepkg --printsrcinfo) .SRCINFO >/dev/null 2>&1; then
problems+=(".SRCINFO does not match PKGBUILD")
fi
if ((${#problems[@]})); then
warn "$pkg: AUR rule violations:"
printf ' - %s\n' "${problems[@]}"
return 1
fi
info "AUR rule checks passed"
return 0
}

148
aur-publish/publish.sh Executable file
View File

@ -0,0 +1,148 @@
#!/bin/bash
# ============================================================================
# Publish kuhy's three Flutter apps to the AUR, hands-off.
#
# Run this once AUR registration is back. It:
# 1. Waits for https://aur.archlinux.org/register to stop returning 503,
# then opens it in a browser and prints the SSH key to paste.
# (Signup is the ONLY manual step: it needs an email confirmation, and
# creating accounts is not something this script will do for you.)
# 2. Waits for `ssh aur@aur.archlinux.org` to authenticate.
# 3. From there, fully unattended, for each package:
# newest release tag -> pkgver -> updpkgsums -> makepkg -C -> namcap
# -> pacman -U -> launch check -> .SRCINFO -> clone AUR repo
# -> commit -> push.
#
# Every package is BUILT, INSTALLED and LAUNCHED before it is pushed, so a
# broken package cannot reach the AUR. A package that fails any gate is
# skipped and reported; the others still publish.
#
# Idempotent and resumable: re-running skips work that is already done, and
# an already-published package is updated rather than duplicated.
# ============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly GATE="$SCRIPT_DIR/anubis_gate.py"
readonly AUR_HOST="aur.archlinux.org"
readonly SSH_KEY="$HOME/.ssh/aur"
readonly AUR_ROOT="$HOME/sdk/aur"
# package:repo:wrapper-process:cli-name:expected-startup-string
# The wrapper process name is NOT derivable from the package name
# (diet-guard-app runs diet_guard_desktop), so it is listed explicitly.
readonly PACKAGES=(
"todo-flutter:$HOME/src/todo:todo_desktop:todo:serving on http://localhost:8730"
"habit-stack:$HOME/src/habit_stack:habit_stack_desktop:habit-stack:serving on http://localhost:8731"
"diet-guard-app:$HOME/src/diet-guard:diet_guard_desktop:diet-guard-app:serving on http://localhost:8732"
)
POLL_SECONDS=300
SKIP_WAIT=0
DRY_RUN=0
ONLY=""
FAILED=()
PUBLISHED=()
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
Options:
--skip-wait Assume the account + SSH key already work; go straight
to building and publishing.
--dry-run Do everything except the final 'git push' to the AUR.
--only NAME Publish just one package (todo-flutter, habit-stack,
diet-guard-app).
--poll SECONDS How often to re-check /register (default: $POLL_SECONDS).
-h, --help Show this help.
EOF
exit 0
}
log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; }
info() { printf ' %s\n' "$*"; }
warn() { printf '\033[1;33m ! %s\033[0m\n' "$*" >&2; }
die() { printf '\033[1;31m==> ERROR: %s\033[0m\n' "$*" >&2; exit 1; }
require_tools() {
local missing=()
local tool
for tool in git makepkg updpkgsums namcap ssh python3 curl; do
command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
done
if ((${#missing[@]})); then
info "Installing missing tools: ${missing[*]}"
# namcap/updpkgsums come from namcap + pacman-contrib; the rest are base.
sudo pacman -S --needed --noconfirm namcap pacman-contrib git openssh python \
|| die "could not install: ${missing[*]}"
fi
}
# ---------------------------------------------------------------------------
# Phase 1: wait for registration, then for the key to work
# ---------------------------------------------------------------------------
# The rest lives in lib/, sourced (not executed) so every function sees the
# readonly config above and the mutable FAILED/PUBLISHED arrays main() reads.
# shellcheck source=lib/aur_registration.sh
source "$SCRIPT_DIR/lib/aur_registration.sh"
# shellcheck source=lib/aur_verify.sh
source "$SCRIPT_DIR/lib/aur_verify.sh"
# shellcheck source=lib/aur_publish_one.sh
source "$SCRIPT_DIR/lib/aur_publish_one.sh"
main() {
require_tools
[[ -f "$GATE" ]] || die "missing $GATE"
mkdir -p "$AUR_ROOT"
ensure_key
if ((SKIP_WAIT)); then
ssh_works || die "SSH key is not accepted yet; drop --skip-wait to wait for it"
else
wait_for_registration
wait_for_ssh
fi
local entry pkg repo wrapper binary expected
for entry in "${PACKAGES[@]}"; do
IFS=: read -r pkg repo wrapper binary expected <<<"$entry"
[[ -z "$ONLY" || "$ONLY" == "$pkg" ]] || continue
if ! publish_one "$pkg" "$repo" "$wrapper" "$binary" "$expected"; then
FAILED+=("$pkg")
fi
done
log "Summary"
if ((${#PUBLISHED[@]})); then
printf ' published: %s\n' "${PUBLISHED[@]}"
fi
if ((${#FAILED[@]})); then
printf '\033[1;31m FAILED: %s\033[0m\n' "${FAILED[@]}"
info "Nothing broken was pushed; fix and re-run (safe to repeat)."
return 1
fi
if ((${#PUBLISHED[@]} == 0)); then
warn "nothing to do"
return 1
fi
printf '\n Check: https://aur.archlinux.org/packages/todo-flutter\n'
return 0
}
while [[ $# -gt 0 ]]; do
case $1 in
--skip-wait) SKIP_WAIT=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--only) ONLY="$2"; shift 2 ;;
--poll) POLL_SECONDS="$2"; shift 2 ;;
-h|--help) usage ;;
*) die "Unknown option: $1" ;;
esac
done
main "$@"

View File

@ -1,120 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Tests for ``tool/client_probe``: the click-free "does the client work" check."""
from __future__ import annotations
import base64
from unittest.mock import MagicMock
import requests
from tool import client_probe as probe
from tool.desktop_client import DesktopClient
_HELD = "stored-client-credential"
_CLIENT = DesktopClient(
client_id="1-abc.apps.googleusercontent.com",
client_secret=_HELD,
project_id="kuhy-syncs",
)
def _location(payload: bytes) -> str:
encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=")
return f"https://accounts.google.com/signin/oauth/error?authError={encoded}&x=1"
def test_auth_location_without_error_is_ok() -> None:
result = probe.classify_auth_location("https://accounts.google.com/v3/signin")
assert result.ok
def test_auth_location_none_is_ok() -> None:
assert probe.classify_auth_location(None).ok
def test_auth_error_is_decoded() -> None:
location = _location(b"\n\x0finvalid_request\x12\x02 Custom URI scheme is bad")
result = probe.classify_auth_location(location)
assert not result.ok
assert "invalid_request" in result.detail
assert "Custom URI scheme" in result.detail
def test_undecodable_auth_error_is_still_a_failure() -> None:
result = probe.classify_auth_location(
"https://accounts.google.com/signin/oauth/error?authError=!!!"
)
assert not result.ok
assert "authError=!!!" in result.detail
def test_token_invalid_grant_means_client_and_secret_are_good() -> None:
assert probe.classify_token(400, {"error": "invalid_grant"}).ok
def test_token_client_not_found_means_still_propagating() -> None:
result = probe.classify_token(
401,
{
"error": "invalid_client",
"error_description": "The OAuth client was not found.",
},
)
assert not result.ok
assert "--verify-only" in result.detail
def test_token_invalid_client_means_wrong_secret() -> None:
result = probe.classify_token(
401, {"error": "invalid_client", "error_description": "Unauthorized"}
)
assert not result.ok
assert "secret" in result.detail
def test_token_other_error_is_reported_verbatim() -> None:
result = probe.classify_token(500, {"error": "backend_error"})
assert not result.ok
assert "backend_error" in result.detail
def test_token_non_json_body_is_reported() -> None:
result = probe.classify_token(502, None)
assert not result.ok
assert "502" in result.detail
def _fakes(location: str | None, status: int, body: object) -> tuple:
get = MagicMock(
return_value=MagicMock(headers={"Location": location} if location else {})
)
post_response = MagicMock(status_code=status)
if body is None:
post_response.json.side_effect = ValueError
else:
post_response.json.return_value = body
return get, MagicMock(return_value=post_response)
def test_probe_client_runs_both_checks() -> None:
get, post = _fakes(None, 400, {"error": "invalid_grant"})
results = probe.probe_client(_CLIENT, get=get, post=post)
assert [r.ok for r in results] == [True, True]
assert get.call_args.kwargs["allow_redirects"] is False
assert post.call_args.kwargs["data"]["client_secret"] == _HELD
def test_probe_client_turns_network_errors_into_failures() -> None:
get = MagicMock(side_effect=requests.ConnectionError("down"))
post = MagicMock(side_effect=requests.ConnectionError("down"))
results = probe.probe_client(_CLIENT, get=get, post=post)
assert [r.ok for r in results] == [False, False]
assert "down" in results[0].detail
def test_probe_client_tolerates_a_non_json_token_reply() -> None:
get, post = _fakes(None, 502, None)
results = probe.probe_client(_CLIENT, get=get, post=post)
assert results[1].ok is False
assert "502" in results[1].detail

View File

@ -1,186 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Tests for ``tool/desktop_client``: picking up and validating the client JSON.
The Android client JSON Google offers for download has the same top-level
``installed`` key and project as a Desktop one, so the negative fixture here is
that exact shape: it must be refused, or the wrong client gets stored.
"""
from __future__ import annotations
import json
import os
import stat
from typing import TYPE_CHECKING
import pytest
from tool import desktop_client as dc
if TYPE_CHECKING:
from pathlib import Path
_CLIENT_ID = "1-abc.apps.googleusercontent.com"
_HELD = "the-desktop-client-credential"
def _installed(**overrides: object) -> dict[str, object]:
body: dict[str, object] = {
"client_id": _CLIENT_ID,
"project_id": "kuhy-syncs",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"client_secret": _HELD,
"redirect_uris": ["http://localhost"],
}
body.update(overrides)
return body
def _text(**overrides: object) -> str:
return json.dumps({"installed": _installed(**overrides)})
def _android_text() -> str:
body = _installed()
del body["client_secret"]
del body["redirect_uris"]
return json.dumps({"installed": body})
def test_valid_desktop_json_parses() -> None:
client = dc.parse_client_json(_text())
assert client == dc.DesktopClient(_CLIENT_ID, _HELD, "kuhy-syncs")
@pytest.mark.parametrize(
("text", "needle"),
[
("{not json", "not valid JSON"),
("[]", "JSON object"),
(json.dumps({"web": _installed()}), "'installed'"),
(_text(project_id="other"), "other"),
(_text(client_id="plain"), "client_id"),
(_android_text(), "client_secret"),
(_text(redirect_uris=["https://x.example"]), "http://localhost"),
(json.dumps({"installed": "nope"}), "'installed'"),
],
)
def test_invalid_json_is_refused_with_a_reason(text: str, needle: str) -> None:
with pytest.raises(dc.ClientFileError, match=needle):
dc.parse_client_json(text)
def _touch(directory: Path, name: str, text: str, mtime: float) -> Path:
path = directory / name
path.write_text(text, encoding="utf-8")
os.utime(path, (mtime, mtime))
return path
def _wait(directory: Path, since: float, skipped: list[str]) -> dc.Pickup | None:
return dc.wait_for_client_json(
directory, since, attempts=3, pause=lambda: None, report=skipped.append
)
def test_pickup_ignores_old_files_and_the_android_json(tmp_path: Path) -> None:
_touch(tmp_path, "client_secret_old.json", _text(), 50.0)
_touch(tmp_path, "client_secret_android.json", _android_text(), 150.0)
_touch(tmp_path, "notes.json", _text(), 150.0)
skipped: list[str] = []
assert _wait(tmp_path, 100.0, skipped) is None
assert len(skipped) == 1
assert "client_secret_android.json" in skipped[0]
assert "client_secret" in skipped[0]
def test_pickup_takes_the_newest_valid_file(tmp_path: Path) -> None:
_touch(
tmp_path,
"client_secret_a.json",
_text(client_id="1-a.apps.googleusercontent.com"),
120.0,
)
newest = _touch(tmp_path, "client_secret_b.json", _text(), 130.0)
found = _wait(tmp_path, 100.0, [])
assert found is not None
assert found.path == newest
assert found.client.client_id == _CLIENT_ID
def test_pickup_waits_for_the_file_to_appear(tmp_path: Path) -> None:
def pause() -> None:
_touch(tmp_path, "client_secret_late.json", _text(), 500.0)
found = dc.wait_for_client_json(tmp_path, 100.0, attempts=3, pause=pause)
assert found is not None
def test_pickup_pauses_between_attempts_only(tmp_path: Path) -> None:
pauses: list[int] = []
result = dc.wait_for_client_json(
tmp_path, 0.0, attempts=3, pause=lambda: pauses.append(1)
)
assert result is None
assert len(pauses) == 2
def test_store_client_is_private_and_consumes_the_download(tmp_path: Path) -> None:
source = _touch(tmp_path, "client_secret_x.json", _text(), 1.0)
dest = tmp_path / "conf" / "desktop_client.json"
dc.store_client(source, dest)
assert stat.S_IMODE(dest.stat().st_mode) == 0o600
assert dc.load_client(dest).client_id == _CLIENT_ID
assert not source.exists()
def test_store_client_overwrites_and_tightens_an_existing_file(tmp_path: Path) -> None:
dest = tmp_path / "desktop_client.json"
dest.write_text("old", encoding="utf-8")
dest.chmod(0o644)
dc.store_client(_touch(tmp_path, "client_secret_y.json", _text(), 1.0), dest)
assert stat.S_IMODE(dest.stat().st_mode) == 0o600
assert dest.read_text(encoding="utf-8") == _text()
def test_load_client_points_at_the_register_script_when_missing(tmp_path: Path) -> None:
with pytest.raises(dc.ClientFileError, match=r"register_desktop_client\.sh"):
dc.load_client(tmp_path / "absent.json")
def test_main_pickup_stores_the_client(tmp_path: Path) -> None:
_touch(tmp_path, "client_secret_z.json", _text(), 200.0)
dest = tmp_path / "out" / "desktop_client.json"
code = dc.main(
[
"pickup",
"--since",
"100",
"--timeout",
"5",
"--downloads",
str(tmp_path),
"--dest",
str(dest),
]
)
assert code == 0
assert dc.load_client(dest).project_id == "kuhy-syncs"
def test_main_pickup_times_out(tmp_path: Path) -> None:
code = dc.main(
[
"pickup",
"--since",
"100",
"--timeout",
"0",
"--downloads",
str(tmp_path),
"--dest",
str(tmp_path / "d.json"),
]
)
assert code == 1

View File

@ -1,64 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Tests for ``tool/_pkce``: the verifier/challenge pair, consent URL, browser."""
from __future__ import annotations
from unittest.mock import MagicMock
import urllib.parse
from tool import _pkce
# The worked example from RFC 7636 appendix B.
_RFC_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
_RFC_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
def test_challenge_matches_the_rfc_vector() -> None:
assert _pkce.challenge_for(_RFC_VERIFIER) == _RFC_CHALLENGE
def test_make_pkce_is_random_and_self_consistent() -> None:
first, second = _pkce.make_pkce(), _pkce.make_pkce()
assert first.verifier != second.verifier
assert 43 <= len(first.verifier) <= 128
assert first.challenge == _pkce.challenge_for(first.verifier)
def test_consent_url_carries_every_pkce_field() -> None:
url = _pkce.build_consent_url(
client_id="cid",
redirect_uri="http://127.0.0.1:5555",
challenge="chal",
state="st",
login_hint="me@example.com",
)
parsed = urllib.parse.urlparse(url)
query = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}
assert parsed.netloc == "accounts.google.com"
assert query == {
"client_id": "cid",
"redirect_uri": "http://127.0.0.1:5555",
"response_type": "code",
"scope": "openid email profile",
"state": "st",
"code_challenge": "chal",
"code_challenge_method": "S256",
"login_hint": "me@example.com",
"prompt": "select_account",
}
def test_open_detached_spawns_a_new_session() -> None:
popen = MagicMock()
which = MagicMock(side_effect=lambda name: f"/usr/bin/{name}")
assert _pkce.open_detached("https://x", popen=popen, which=which) is True
args, kwargs = popen.call_args
assert args[0] == ["/usr/bin/setsid", "/usr/bin/xdg-open", "https://x"]
assert kwargs["start_new_session"] is True
def test_open_detached_reports_missing_tools() -> None:
popen = MagicMock()
which = MagicMock(return_value=None)
assert _pkce.open_detached("https://x", popen=popen, which=which) is False
popen.assert_not_called()

View File

@ -1,38 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The todo MCP server's desktop session is seeded by ``seed_session``.
Split from :mod:`test_tool_seed_session_main` for the 250-line cap.
"""
from __future__ import annotations
from datetime import UTC, datetime
import json
from pathlib import Path
from unittest.mock import patch
from crdt_sync._config import credential_store_for
from crdt_sync._credentials import FirebaseCredentials
from tool import seed_session as ss
_APP = "todo-mcp"
def test_todo_mcp_is_a_default_app() -> None:
"""``~/src/todo/mcp`` reads ``~/.config/todo-mcp/firebase_auth.json`` once
at start; ``--app todo-mcp`` is rejected by argparse unless it is listed."""
assert _APP in ss.DEFAULT_APPS
def test_the_seed_lands_where_the_todo_mcp_server_reads_it(tmp_path: Path) -> None:
"""The Dart server's ``seedCredentials`` path, in the usual shape."""
with patch.object(Path, "home", return_value=tmp_path):
credential_store_for(_APP).save(
FirebaseCredentials("fake-id", "fake-refresh", datetime.now(UTC))
)
written = tmp_path / ".config" / _APP / "firebase_auth.json"
assert set(json.loads(written.read_text())) == {
"id_token",
"refresh_token",
"expires_at",
}

View File

@ -1,156 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Tests for ``tool/verify_desktop_client``: the PKCE loopback + Firebase check.
No browser, no network, no socket: the consent step, the code exchange and
Firebase's REST endpoint are all faked at the module boundary.
"""
from __future__ import annotations
import base64
import contextlib
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from crdt_sync._autherrors import FirebaseAuthError
from crdt_sync._config import FirebaseConfig
from tool import verify_desktop_client as vdc
from tool._token_exchange import TokenError, _exchange_code
from tool.desktop_client import DesktopClient
if TYPE_CHECKING:
from collections.abc import Iterator
_HELD = "stored-client-credential"
_CLIENT = DesktopClient("1-abc.apps.googleusercontent.com", _HELD, "kuhy-syncs")
_CONFIG = FirebaseConfig("api", "https://db", "kuhy-syncs", "uid-1", "me@example.com")
_ID_VALUE = (
"header."
+ base64.urlsafe_b64encode(json.dumps({"aud": _CLIENT.client_id}).encode())
.decode()
.rstrip("=")
+ ".sig"
)
def test_audience_of_reads_the_aud_claim() -> None:
assert vdc.audience_of(_ID_VALUE) == _CLIENT.client_id
@pytest.mark.parametrize(
"bad", ["", "a.b", "a.!!!.c", "a." + base64.b64encode(b"[]").decode() + ".c"]
)
def test_audience_of_survives_garbage(bad: str) -> None:
assert vdc.audience_of(bad) == "?"
@pytest.mark.parametrize(
("message", "needle"),
[
(
"failed: HTTP 400 (INVALID_IDP_RESPONSE : id_token audience mismatch)",
"Safelist",
),
("signed in as the wrong account: Google resolved to uid 'x'", "login_hint"),
("anything else", ""),
],
)
def test_hint_for(message: str, needle: str) -> None:
hint = vdc.hint_for(message)
assert (needle in hint) if needle else hint == ""
@contextlib.contextmanager
def _flow(result: dict[str, str]) -> Iterator[MagicMock]:
"""Fake the socket, the callback and the exchange; yield the exchange."""
with (
patch.object(vdc, "_free_port", return_value=5555),
patch.object(
vdc, "_start_callback_server", return_value=(MagicMock(), MagicMock())
),
patch.object(vdc._CallbackHandler, "result", result),
patch.object(vdc, "_exchange_code", return_value=_ID_VALUE) as exchange,
patch.object(
vdc.secrets,
"token_urlsafe",
side_effect=lambda nbytes: "st" if nbytes == 16 else "v" * 86,
),
):
yield exchange
def test_fetch_id_token_sends_pkce_and_the_secret() -> None:
opener = MagicMock(return_value=True)
with _flow({"code": "abc", "state": "st"}) as exchange:
token = vdc.fetch_id_token(_CLIENT, "me@example.com", opener=opener)
assert token == _ID_VALUE
url = opener.call_args.args[0]
assert "redirect_uri=http%3A%2F%2F127.0.0.1%3A5555" in url
assert "code_challenge_method=S256" in url
code, client_id, held, redirect, verifier = exchange.call_args.args
assert (code, client_id, held) == ("abc", _CLIENT.client_id, _HELD)
assert redirect == "http://127.0.0.1:5555"
assert len(verifier) >= 43
def test_fetch_id_token_without_a_browser_still_waits() -> None:
opener = MagicMock(return_value=False)
with _flow({"code": "abc", "state": "st"}):
token = vdc.fetch_id_token(_CLIENT, "", open_browser=False, opener=opener)
assert token == _ID_VALUE
opener.assert_not_called()
def test_fetch_id_token_reports_a_refused_consent() -> None:
with (
_flow({"error": "access_denied"}),
pytest.raises(TokenError, match="access_denied"),
):
vdc.fetch_id_token(_CLIENT, "", opener=MagicMock())
def _firebase_reply(*, ok: bool, body: dict[str, object]) -> MagicMock:
response = MagicMock(ok=ok, status_code=200 if ok else 400)
response.json.return_value = body
return response
def test_sign_in_returns_the_email_and_writes_no_file() -> None:
reply = _firebase_reply(
ok=True,
body={
"localId": "uid-1",
"email": "me@example.com",
"idToken": "i",
"refreshToken": "r",
"expiresIn": "3600",
},
)
with patch("crdt_sync._firebase_auth.requests.post", return_value=reply) as post:
assert vdc.sign_in(_CONFIG, _ID_VALUE) == "me@example.com"
assert "signInWithIdp" in post.call_args.args[0]
def test_sign_in_surfaces_the_exact_firebase_error() -> None:
reply = _firebase_reply(
ok=False,
body={
"error": {"message": "INVALID_IDP_RESPONSE : id_token audience mismatch"}
},
)
with (
patch("crdt_sync._firebase_auth.requests.post", return_value=reply),
pytest.raises(FirebaseAuthError, match="audience mismatch"),
):
vdc.sign_in(_CONFIG, _ID_VALUE)
def test_exchange_sends_the_pkce_verifier() -> None:
reply = MagicMock(ok=True)
reply.json.return_value = {"id_token": _ID_VALUE}
with patch("tool._token_exchange.requests.post", return_value=reply) as post:
_exchange_code("c", "id", _HELD, "http://127.0.0.1:1", "ver")
assert post.call_args.kwargs["data"]["code_verifier"] == "ver"

View File

@ -1,125 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Tests for ``verify_desktop_client.main``: every exit path and its message."""
from __future__ import annotations
import base64
import json
import logging
from unittest.mock import MagicMock, patch
import pytest
from crdt_sync._autherrors import FirebaseAuthError
from crdt_sync._config import ConfigError, FirebaseConfig
from tool import verify_desktop_client as vdc
from tool._token_exchange import TokenError
from tool.client_probe import ProbeResult
from tool.desktop_client import ClientFileError, DesktopClient
_HELD = "stored-client-credential"
_CLIENT = DesktopClient("1-abc.apps.googleusercontent.com", _HELD, "kuhy-syncs")
_CONFIG = FirebaseConfig("api", "https://db", "kuhy-syncs", "uid-1", "me@example.com")
_ID_VALUE = (
"header."
+ base64.urlsafe_b64encode(json.dumps({"aud": _CLIENT.client_id}).encode())
.decode()
.rstrip("=")
+ ".sig"
)
def _run(
*,
probe_ok: bool = True,
token: object = _ID_VALUE,
sign_in: object = "me@example.com",
argv: list[str] | None = None,
) -> tuple[int, MagicMock]:
fetch = MagicMock(
side_effect=token if isinstance(token, Exception) else None, return_value=token
)
signer = MagicMock(
side_effect=sign_in if isinstance(sign_in, Exception) else None,
return_value=sign_in,
)
with (
patch.object(vdc.FirebaseConfig, "load", return_value=_CONFIG),
patch.object(vdc, "load_client", return_value=_CLIENT),
patch.object(
vdc, "probe_client", return_value=[ProbeResult(probe_ok, "probed")]
),
patch.object(vdc, "fetch_id_token", fetch),
patch.object(vdc, "sign_in", signer),
):
return vdc.main(argv or []), fetch
def test_main_passes_and_names_the_uid(caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level(logging.INFO)
code, _ = _run()
assert code == 0
assert "PASS" in caplog.text
assert "uid-1" in caplog.text
assert "me@example.com" in caplog.text
def test_main_stops_before_consent_when_the_probe_fails(
caplog: pytest.LogCaptureFixture,
) -> None:
caplog.set_level(logging.INFO)
code, fetch = _run(probe_ok=False)
assert code == 1
assert "FAIL" in caplog.text
fetch.assert_not_called()
def test_main_probe_only_never_opens_consent() -> None:
code, fetch = _run(argv=["--probe-only"])
assert code == 0
fetch.assert_not_called()
def test_main_skip_probe_goes_straight_to_consent() -> None:
code, fetch = _run(probe_ok=False, argv=["--skip-probe", "--no-browser"])
assert code == 0
assert fetch.call_args.kwargs["open_browser"] is False
@pytest.mark.parametrize(
("kwargs", "needle"),
[
(
{"token": TokenError("token exchange failed: HTTP 400 invalid_grant")},
"invalid_grant",
),
(
{
"sign_in": FirebaseAuthError(
"INVALID_IDP_RESPONSE : id_token audience mismatch"
)
},
"Safelist",
),
],
)
def test_main_prints_the_exact_error_and_exits_1(
kwargs: dict[str, object], needle: str, caplog: pytest.LogCaptureFixture
) -> None:
caplog.set_level(logging.INFO)
code, _ = _run(**kwargs)
assert code == 1
assert "FAIL" in caplog.text
assert needle in caplog.text
@pytest.mark.parametrize(
"error", [ConfigError("no config"), ClientFileError("no client")]
)
def test_main_reports_setup_errors(
error: Exception, caplog: pytest.LogCaptureFixture
) -> None:
caplog.set_level(logging.INFO)
with patch.object(vdc.FirebaseConfig, "load", side_effect=error):
assert vdc.main([]) == 1
assert "FAIL" in caplog.text

View File

@ -1,85 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""PKCE pair, consent URL and detached browser launch for the loopback flow."""
from __future__ import annotations
import base64
from dataclasses import dataclass
import hashlib
import secrets
import shutil
import subprocess
from typing import TYPE_CHECKING
import urllib.parse
if TYPE_CHECKING:
from collections.abc import Callable
AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"
SCOPES = "openid email profile"
_VERIFIER_BYTES = 64
@dataclass(frozen=True)
class Pkce:
"""A code verifier and the S256 challenge derived from it."""
verifier: str
challenge: str
def challenge_for(verifier: str) -> str:
"""Return the unpadded base64url SHA-256 of ``verifier`` (RFC 7636 S256)."""
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
def make_pkce() -> Pkce:
"""Return a fresh 86-char verifier and its challenge."""
verifier = secrets.token_urlsafe(_VERIFIER_BYTES)
return Pkce(verifier=verifier, challenge=challenge_for(verifier))
def build_consent_url(
*,
client_id: str,
redirect_uri: str,
challenge: str,
state: str,
login_hint: str,
) -> str:
"""Return the consent URL, with the sync account preselected."""
query = urllib.parse.urlencode(
{
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": SCOPES,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
"login_hint": login_hint,
"prompt": "select_account",
}
)
return f"{AUTH_ENDPOINT}?{query}"
def open_detached(
url: str,
*,
popen: Callable[..., object] = subprocess.Popen,
which: Callable[[str], str | None] = shutil.which,
) -> bool:
"""Open ``url`` via ``setsid xdg-open``; False if either tool is missing."""
setsid, opener = which("setsid"), which("xdg-open")
if not setsid or not opener:
return False
popen(
[setsid, opener, url],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
return True

View File

@ -67,8 +67,4 @@ DEFAULT_APPS: Final = (
# /kartka/source_health via credential_store_for("kartka-source-eval")
# and falls back to the retired password only with no session here.
"kartka-source-eval",
# ~/src/todo/mcp: the Dart todo MCP server (systemd --user, :8735) is one
# more sync node with its own session, read once from
# ~/.config/todo-mcp/firebase_auth.json at start.
"todo-mcp",
)

View File

@ -44,17 +44,9 @@ def _authorization_code(params: dict[str, str], redirect_uri: str, state: str) -
def _exchange_code(
code: str,
client_id: str,
client_secret: str,
redirect_uri: str,
code_verifier: str = "",
code: str, client_id: str, client_secret: str, redirect_uri: str
) -> str:
"""Trade the authorization code for an ID token at Google's endpoint.
``code_verifier`` is sent only when given, for PKCE flows.
"""
pkce = {"code_verifier": code_verifier} if code_verifier else {}
"""Trade the authorization code for an ID token at Google's endpoint."""
try:
response = requests.post(
_TOKEN_EXCHANGE_URL,
@ -64,7 +56,6 @@ def _exchange_code(
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
**pkce,
},
timeout=_TIMEOUT_SECONDS,
)

View File

@ -1,158 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Click-free checks that a freshly stored Desktop OAuth client actually works.
Both requests need no signed-in user: Google's endpoints answer them before any
account is involved, so they say whether the client exists, accepts a loopback
redirect and whether the stored secret is right -- without opening a browser.
That separates "the console step went wrong" from "Firebase rejects the
audience" before the one human consent click is spent.
"""
from __future__ import annotations
import base64
from dataclasses import dataclass
import re
from typing import TYPE_CHECKING
import urllib.parse
import requests
from tool._pkce import AUTH_ENDPOINT, challenge_for
if TYPE_CHECKING:
from collections.abc import Callable
from tool.desktop_client import DesktopClient
# Assembled from host and path: a literal ending in "/token" trips ruff's
# hardcoded-password check, and suppressions are banned in this repo.
_OAUTH_HOST = "https://oauth2.googleapis.com"
_TOKEN_URL = f"{_OAUTH_HOST}/token"
_PROBE_REDIRECT = "http://127.0.0.1:41234"
_TIMEOUT_SECONDS = 20
_ASCII_RUN = re.compile(rb"[ -~]{6,}")
_PROBE_VERIFIER = "probe-verifier-" + "x" * 30
@dataclass(frozen=True)
class ProbeResult:
"""One check's verdict and the words that explain it."""
ok: bool
detail: str
def classify_auth_location(location: str | None) -> ProbeResult:
"""Judge the auth endpoint's redirect target.
A valid request while signed out still redirects (to the sign-in page), so
the status code means nothing: only an ``authError`` parameter in the
target says Google refused the client or its redirect URI.
"""
if not location or "authError=" not in location:
return ProbeResult(
ok=True, detail="auth endpoint accepts the loopback redirect"
)
raw = location.split("authError=", 1)[1].split("&", 1)[0]
try:
decoded = base64.b64decode(
raw + "=" * (-len(raw) % 4), altchars=b"-_", validate=True
)
except ValueError:
return ProbeResult(
ok=False, detail=f"auth endpoint refused the request (authError={raw})"
)
text = " | ".join(run.decode("ascii") for run in _ASCII_RUN.findall(decoded))
return ProbeResult(
ok=False, detail=f"auth endpoint refused the request: {text[:300]}"
)
def classify_token(status: int, body: dict[str, str] | None) -> ProbeResult:
"""Judge the token endpoint's answer to a deliberately bogus code.
``invalid_grant`` is the success signal: Google checked the client and
secret first, accepted them, and only then rejected the fake code.
"""
if body is None:
return ProbeResult(ok=False, detail=f"token endpoint: HTTP {status}, no JSON")
error = body.get("error", "")
description = body.get("error_description", "")
if error == "invalid_grant":
return ProbeResult(
ok=True, detail="token endpoint accepts the client and secret"
)
if error == "invalid_client" and "not found" in description:
return ProbeResult(
ok=False,
detail=(
"client not found yet: new clients take a few minutes to "
"propagate; rerun with --verify-only shortly"
),
)
if error == "invalid_client":
return ProbeResult(
ok=False, detail=f"the stored secret is wrong ({description})"
)
return ProbeResult(ok=False, detail=f"token endpoint: HTTP {status} {body}")
def _probe_auth(
client: DesktopClient, get: Callable[..., requests.Response]
) -> ProbeResult:
query = urllib.parse.urlencode(
{
"client_id": client.client_id,
"redirect_uri": _PROBE_REDIRECT,
"response_type": "code",
"scope": "openid email",
"code_challenge": challenge_for(_PROBE_VERIFIER),
"code_challenge_method": "S256",
"hl": "en",
}
)
try:
reply = get(
f"{AUTH_ENDPOINT}?{query}",
allow_redirects=False,
timeout=_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
return ProbeResult(ok=False, detail=f"auth endpoint unreachable: {exc}")
return classify_auth_location(reply.headers.get("Location"))
def _probe_token(
client: DesktopClient, post: Callable[..., requests.Response]
) -> ProbeResult:
try:
answer = post(
_TOKEN_URL,
data={
"code": "probe-not-a-real-code",
"client_id": client.client_id,
"client_secret": client.client_secret,
"redirect_uri": _PROBE_REDIRECT,
"grant_type": "authorization_code",
"code_verifier": _PROBE_VERIFIER,
},
timeout=_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
return ProbeResult(ok=False, detail=f"token endpoint unreachable: {exc}")
try:
body = answer.json()
except ValueError:
body = None
return classify_token(answer.status_code, body)
def probe_client(
client: DesktopClient,
*,
get: Callable[..., requests.Response] = requests.get,
post: Callable[..., requests.Response] = requests.post,
) -> list[ProbeResult]:
"""Run the auth-endpoint and token-endpoint checks for ``client``."""
return [_probe_auth(client, get), _probe_token(client, post)]

View File

@ -1,173 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Pick up, validate and store the Desktop OAuth client JSON from the console.
Android and iOS clients download as the same top-level ``installed`` object, so
the type alone proves nothing: only a ``client_secret`` plus the
``http://localhost`` redirect marks a Desktop client.
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
from functools import partial
import json
import logging
import os
from pathlib import Path
import sys
import time
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
PROJECT_ID = "kuhy-syncs"
DEFAULT_DEST = Path.home() / ".config" / "crdt-sync" / "desktop_client.json"
DEFAULT_DOWNLOADS = Path.home() / "Downloads"
_POLL_SECONDS = 2.0
_pause_between_polls = partial(time.sleep, _POLL_SECONDS)
_CLIENT_ID_SUFFIX = ".apps.googleusercontent.com"
_LOOPBACK_REDIRECT = "http://localhost"
_logger = logging.getLogger("desktop_client")
class ClientFileError(Exception):
"""The client JSON is missing, malformed or not a Desktop client."""
@dataclass(frozen=True)
class DesktopClient:
"""The installed-app client as downloaded from the console."""
client_id: str
client_secret: str
project_id: str
@dataclass(frozen=True)
class Pickup:
"""A valid client JSON found in the downloads folder."""
path: Path
client: DesktopClient
def parse_client_json(text: str, project_id: str = PROJECT_ID) -> DesktopClient:
"""Return the Desktop client in ``text``, or raise saying what is wrong."""
try:
document = json.loads(text)
except ValueError as exc:
msg = f"not valid JSON: {exc}"
raise ClientFileError(msg) from exc
if not isinstance(document, dict):
msg = "must contain a JSON object"
raise ClientFileError(msg)
installed = document.get("installed")
if not isinstance(installed, dict):
msg = f"no 'installed' object (found {sorted(document)}); pick 'Desktop app'"
raise ClientFileError(msg)
if installed.get("project_id") != project_id:
msg = f"belongs to project {installed.get('project_id')!r}, not {project_id!r}"
raise ClientFileError(msg)
client_id = str(installed.get("client_id", ""))
if not client_id.endswith(_CLIENT_ID_SUFFIX):
msg = f"client_id {client_id!r} is not a Google OAuth client id"
raise ClientFileError(msg)
held = str(installed.get("client_secret", ""))
if not held:
msg = "no client_secret: this is an Android/iOS client, not a Desktop app"
raise ClientFileError(msg)
if _LOOPBACK_REDIRECT not in installed.get("redirect_uris", []):
msg = f"redirect_uris lacks {_LOOPBACK_REDIRECT}: not a Desktop app client"
raise ClientFileError(msg)
return DesktopClient(client_id=client_id, client_secret=held, project_id=project_id)
def _scan(
downloads: Path, since: float, rejected: set[str], report: Callable[[str], None]
) -> Pickup | None:
candidates = [
path
for path in downloads.glob("client_secret_*.json")
if path.stat().st_mtime >= since
]
for path in sorted(candidates, key=lambda p: p.stat().st_mtime, reverse=True):
try:
client = parse_client_json(path.read_text(encoding="utf-8"))
except ClientFileError as exc:
if path.name not in rejected:
rejected.add(path.name)
report(f"ignoring {path.name}: {exc}")
continue
return Pickup(path=path, client=client)
return None
def wait_for_client_json(
downloads: Path,
since: float,
*,
attempts: int,
pause: Callable[[], None] = _pause_between_polls,
report: Callable[[str], None] = print,
) -> Pickup | None:
"""Scan ``downloads`` up to ``attempts`` times for a JSON newer than ``since``."""
rejected: set[str] = set()
for attempt in range(attempts):
found = _scan(downloads, since, rejected, report)
if found:
return found
if attempt < attempts - 1:
pause()
return None
def store_client(source: Path, dest: Path) -> None:
"""Move ``source`` to ``dest`` with mode 0600, so no copy lingers in Downloads."""
dest.parent.mkdir(parents=True, exist_ok=True)
descriptor = os.open(dest, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
handle.write(source.read_text(encoding="utf-8"))
dest.chmod(0o600)
source.unlink()
def load_client(dest: Path = DEFAULT_DEST) -> DesktopClient:
"""Load the stored client, pointing at the register script when absent."""
try:
return parse_client_json(dest.read_text(encoding="utf-8"))
except OSError as exc:
msg = f"{dest} unreadable ({exc}); run tool/register_desktop_client.sh"
raise ClientFileError(msg) from exc
def main(argv: list[str] | None = None) -> int:
"""Wait for the downloaded JSON and store it. Returns an exit code."""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("action", choices=["pickup"])
parser.add_argument("--since", type=float, required=True, help="epoch seconds")
parser.add_argument("--timeout", type=float, default=600.0)
parser.add_argument("--downloads", type=Path, default=DEFAULT_DOWNLOADS)
parser.add_argument("--dest", type=Path, default=DEFAULT_DEST)
args = parser.parse_args(argv)
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(message)s")
found = wait_for_client_json(
args.downloads,
args.since,
attempts=int(args.timeout / _POLL_SECONDS) + 1,
report=_logger.info,
)
if found is None:
_logger.error(" FAIL no Desktop client JSON appeared in %s", args.downloads)
return 1
store_client(found.path, args.dest)
_logger.info(
" PASS stored %s at %s (mode 600)", found.client.client_id, args.dest
)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,127 +0,0 @@
#!/bin/bash
# Registers the Desktop OAuth client (no API exists, so one console step stays
# manual), stores its JSON privately, then proves Firebase accepts its tokens.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
readonly SCRIPT_DIR REPO_DIR
readonly PROJECT="kuhy-syncs"
readonly ACCOUNT="321krzychu@gmail.com"
readonly CONSOLE_URL="https://console.cloud.google.com/auth/clients?project=${PROJECT}&authuser=${ACCOUNT}"
readonly APP_TYPE="Desktop app"
readonly CLIENT_NAME="crdt-sync desktop/android PKCE"
readonly PAUSE_SECONDS="${PAUSE_SECONDS:-12}"
readonly WAIT_SECONDS="${WAIT_SECONDS:-900}"
readonly PROBE_TRIES="${PROBE_TRIES:-12}"
readonly PROBE_GAP_SECONDS="${PROBE_GAP_SECONDS:-30}"
log() { printf '\n\033[1m==> %s\033[0m\n' "$1"; }
step() { printf ' %s\n' "$1"; }
usage() {
cat <<EOF
Usage: $(basename "$0") [--verify-only | --dry-run | --help]
(no flag) open the console, clipboard-feed the form, pick up the
downloaded JSON, store it, then verify
--verify-only skip registration; probe, consent flow, signInWithIdp
--dry-run print the plan; open, copy and write nothing
Your part: Application type = Desktop app, paste the name, CREATE, DOWNLOAD
JSON in the dialog; later tap Allow on the consent page.
Stored at ~/.config/crdt-sync/desktop_client.json (mode 600).
EOF
}
# Prints the value always, so this works over ssh with no X display too.
clip() {
if command -v xclip >/dev/null 2>&1 && [[ -n "${DISPLAY:-}" ]]; then
printf '%s' "$1" | xclip -selection clipboard 2>/dev/null || true
printf ' \033[32m[copied]\033[0m %s\n' "$1"
else
printf ' %s\n' "$1"
fi
}
# Without a terminal (e.g. run via `!`) read would hit EOF at once.
pause() {
if [[ -t 0 ]]; then
read -rp " ...press Enter once pasted " _ || true
else
sleep "$PAUSE_SECONDS"
fi
}
open_console() {
if command -v xdg-open >/dev/null 2>&1; then
setsid xdg-open "$CONSOLE_URL" >/dev/null 2>&1 &
else
step "open: $CONSOLE_URL"
fi
}
run_tool() { (cd "$REPO_DIR" && python3 -m "tool.$1" "${@:2}"); }
wait_for_propagation() {
local try
for ((try = 1; try <= PROBE_TRIES; try++)); do
if run_tool verify_desktop_client --probe-only; then
return 0
fi
step "probe $try/$PROBE_TRIES failed; new clients need a few minutes"
sleep "$PROBE_GAP_SECONDS"
done
echo "error: client still not accepted; rerun with --verify-only later" >&2
return 1
}
verify() {
log "Verifying: probe, consent flow, Firebase signInWithIdp"
step "Tap Allow on the sync account's consent page when it opens."
run_tool verify_desktop_client
}
register() {
local started
started="$(date +%s)"
log "Create ONE Desktop OAuth client in project $PROJECT"
open_console
sleep 2
step "Click '+ CREATE CLIENT'. Application type (paste or pick):"
clip "$APP_TYPE"
pause
step "Field 'Name':"
clip "$CLIENT_NAME"
pause
step "Click CREATE, then DOWNLOAD JSON in the dialog (the secret shows once)."
step "Leave the existing Web and Android clients alone."
log "Waiting up to ${WAIT_SECONDS}s for the downloaded client_secret_*.json"
run_tool desktop_client pickup --since "$started" --timeout "$WAIT_SECONDS"
wait_for_propagation
verify
}
dry_run() {
log "Dry run: nothing is opened, copied or written"
step "page: $CONSOLE_URL"
step "type: $APP_TYPE"
step "name: $CLIENT_NAME"
step "stores at: ~/.config/crdt-sync/desktop_client.json (mode 600)"
step "verify: python3 -m tool.verify_desktop_client (in $REPO_DIR)"
}
main() {
case "${1:-}" in
--verify-only) verify ;;
--dry-run) dry_run ;;
-h | --help) usage ;;
"") register ;;
*)
usage >&2
exit 2
;;
esac
}
main "$@"

View File

@ -1,165 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Prove Firebase accepts an id_token issued to the stored Desktop OAuth client.
Runs the PKCE loopback flow with the installed-app client (secret included in
the exchange), then calls Firebase Auth REST ``signInWithIdp`` with the result.
The consent click in the browser is the only human step. No app credential
file is written: the session lives in memory and is dropped.
Run from the crdt-sync directory::
python3 -m tool.verify_desktop_client
"""
from __future__ import annotations
import argparse
import base64
import json
import logging
import secrets
import sys
from typing import TYPE_CHECKING
from crdt_sync._autherrors import FirebaseAuthError
from crdt_sync._config import ConfigError, FirebaseConfig
from crdt_sync._credentials import MemoryCredentialStore
from crdt_sync._firebase_auth import FirebaseTokenProvider
from tool._oauth_callback import (
_CONSENT_TIMEOUT_SECONDS,
_CallbackHandler,
_free_port,
_start_callback_server,
)
from tool._pkce import build_consent_url, make_pkce, open_detached
from tool._token_exchange import TokenError, _authorization_code, _exchange_code
from tool.client_probe import probe_client
from tool.desktop_client import ClientFileError, DesktopClient, load_client
if TYPE_CHECKING:
from collections.abc import Callable
_logger = logging.getLogger("verify_desktop_client")
def audience_of(id_token: str) -> str:
"""Return the ``aud`` claim of an unverified JWT, or ``?`` if unreadable."""
try:
segment = id_token.split(".")[1]
claims = json.loads(
base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))
)
return str(claims["aud"])
except IndexError, ValueError, KeyError, TypeError:
return "?"
def hint_for(message: str) -> str:
"""Return what to do about a known failure message, or an empty string."""
if "audience mismatch" in message:
return (
"Firebase does not trust this client yet: Firebase console > "
"Authentication > Sign-in method > Google > Safelist client IDs "
"from external projects, add the Desktop client id, then rerun."
)
if "wrong account" in message:
return (
"Pick the sync account in the consent picker (login_hint only "
"preselects it). Firebase may have created a stray user for the "
"other account: check Authentication > Users."
)
return ""
def fetch_id_token(
client: DesktopClient,
login_hint: str,
*,
open_browser: bool = True,
opener: Callable[[str], bool] = open_detached,
) -> str:
"""Run the PKCE loopback flow with ``client`` and return its id_token."""
port = _free_port()
redirect_uri = f"http://127.0.0.1:{port}"
state = secrets.token_urlsafe(16)
pkce = make_pkce()
server, thread = _start_callback_server(port, state)
url = build_consent_url(
client_id=client.client_id,
redirect_uri=redirect_uri,
challenge=pkce.challenge,
state=state,
login_hint=login_hint,
)
_logger.info(" .. consent page (opened in your browser, tap Allow):")
_logger.info(" %s", url)
if open_browser:
opener(url)
thread.join(timeout=_CONSENT_TIMEOUT_SECONDS)
server.server_close()
code = _authorization_code(_CallbackHandler.result, redirect_uri, state)
return _exchange_code(
code, client.client_id, client.client_secret, redirect_uri, pkce.verifier
)
def sign_in(config: FirebaseConfig, id_token: str) -> str:
"""Call ``signInWithIdp`` in memory; return the account email.
Raises:
FirebaseAuthError: With Firebase's exact message, or on a uid other
than the pinned one.
"""
provider = FirebaseTokenProvider(config.api_key, MemoryCredentialStore())
return provider.sign_in_with_google(id_token, expected_uid=config.uid)
def _fail(message: str) -> int:
_logger.error(" FAIL %s", message)
hint = hint_for(message)
if hint:
_logger.error(" .. %s", hint)
return 1
def main(argv: list[str] | None = None) -> int:
"""Probe the stored client, run the consent flow, sign in to Firebase."""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--no-browser", action="store_true", help="only print the URL")
parser.add_argument(
"--skip-probe", action="store_true", help="skip click-free checks"
)
parser.add_argument(
"--probe-only", action="store_true", help="stop after the click-free checks"
)
args = parser.parse_args(argv)
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(message)s")
try:
config = FirebaseConfig.load()
client = load_client()
if not args.skip_probe:
results = probe_client(client)
for result in results:
_logger.info(" %s %s", "ok" if result.ok else "!!", result.detail)
failed = [r.detail for r in results if not r.ok]
if failed:
return _fail("; ".join(failed))
if args.probe_only:
return 0
id_token = fetch_id_token(
client, config.email, open_browser=not args.no_browser
)
_logger.info(" .. id_token audience: %s", audience_of(id_token))
email = sign_in(config, id_token)
except (ConfigError, ClientFileError, TokenError, FirebaseAuthError) as exc:
return _fail(str(exc))
_logger.info(
" PASS Firebase accepted the Desktop client: uid %s, %s", config.uid, email
)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -118,16 +118,6 @@ The public/private split is not cosmetic: every repo in this fleet is public.
APK — the security rules, not secrecy, protect the data. The account email and
password identify *a person* and are entered once per device.
### Sandbox namespace for test builds
`--dart-define=SYNC_NAMESPACE=sandbox/slot3` moves every RTDB read and write
under `/sandbox/slot3/...`, so a test build never touches real data. Sign-in
is unchanged (the real account). Empty, the default, keeps the exact current
paths. Only `^sandbox/[a-z0-9_-]+$` is accepted; anything else throws
`ConfigException` at startup (`firebaseClientFor` / `FirebaseRestClient`), never
falling back to production paths. The shipped rules (root-level uid pin) already
allow these paths.
### `password: ''` is legal
The Google one-tap path and the seeded-session path both store an empty

View File

@ -20,5 +20,4 @@ export 'src/remote_store.dart';
export 'src/secure_credential_store.dart';
export 'src/store.dart';
export 'src/sync.dart';
export 'src/sync_namespace.dart';
export 'src/sync_state.dart';

View File

@ -19,7 +19,6 @@ import 'firebase_auth_rest.dart';
import 'firebase_errors.dart';
import 'firebase_keys.dart';
import 'remote_store.dart';
import 'sync_namespace.dart';
const _httpUnauthorized = 401;
const _httpForbidden = 403;
@ -34,22 +33,13 @@ class FirebaseRestClient implements RemoteStore, BulkMapReader {
required String databaseUrl,
required this.auth,
http.Client? httpClient,
String namespace = syncNamespace,
}) : _databaseUrl = databaseUrl.endsWith('/')
? databaseUrl.substring(0, databaseUrl.length - 1)
: databaseUrl,
_prefix = _prefixFor(checkSyncNamespace(namespace)),
_http = httpClient ?? http.Client();
final String _databaseUrl;
/// `<namespace>/` or empty: put in front of every RTDB path. Every request
/// goes through [_uri], so this is the single choke point.
final String _prefix;
static String _prefixFor(String namespace) =>
namespace.isEmpty ? '' : '$namespace/';
/// Mints the ID token every request carries. Public so a settings screen
/// can drive `signIn` / `signOut` through the same instance the client uses.
final FirebaseTokenProvider auth;
@ -57,12 +47,8 @@ class FirebaseRestClient implements RemoteStore, BulkMapReader {
Future<Uri> _uri(String path, {Map<String, String> query = const {}}) async {
final encoded = encodePath(path);
// A bare namespace (the root probe) must not leave a trailing slash.
final full = encoded.isEmpty && _prefix.isNotEmpty
? _prefix.substring(0, _prefix.length - 1)
: '$_prefix$encoded';
return Uri.parse(
'$_databaseUrl/$full.json',
'$_databaseUrl/$encoded.json',
).replace(queryParameters: {'auth': await auth.idToken(), ...query});
}

View File

@ -20,7 +20,6 @@ import 'firebase_credentials.dart';
import 'firebase_client.dart';
import 'mirror_store.dart';
import 'remote_store.dart';
import 'sync_namespace.dart';
/// The shared config is missing a field, or still holds a scaffold placeholder.
class ConfigException implements Exception {
@ -122,8 +121,6 @@ Future<FirebaseRestClient> firebaseClientFor({
String? expectedUid,
http.Client? httpClient,
}) async {
// Before any sign-in: a bad SYNC_NAMESPACE must stop the app at startup.
checkSyncNamespace(syncNamespace);
final auth = FirebaseTokenProvider(
apiKey: config.apiKey,
store: store,

View File

@ -7,11 +7,8 @@ import 'store.dart';
/// Writes atomically -- to a per-process temp file, then `rename`d over the
/// real path -- so a concurrent reader (e.g. a background sync isolate writing
/// while the foreground app also writes) never observes a half-written file.
/// The pid in the temp name keeps two processes from colliding; within one
/// instance, writes are serialized in issue order (last issued wins), so
/// overlapping calls (e.g. per-keystroke saves) never share the temp file
/// mid-flight. This generalizes diet_guard's hand-rolled `LogStorageService`
/// write scheme.
/// The pid in the temp name keeps two writers from colliding. This generalizes
/// diet_guard's hand-rolled `LogStorageService` write scheme.
///
/// Lives behind the `package:crdt_sync/crdt_sync_io.dart` entrypoint, not the
/// main barrel, because it imports `dart:io` and so is unavailable on web --
@ -23,10 +20,6 @@ class FileLogPersistence implements LogPersistence {
final File _file;
/// Completes when the most recently issued write has settled. Never
/// rejects, so one failed write cannot block the ones queued after it.
Future<void> _tail = Future<void>.value();
@override
Future<String?> read() async {
if (!_file.existsSync()) return null;
@ -38,14 +31,7 @@ class FileLogPersistence implements LogPersistence {
}
@override
Future<void> write(String text) {
final result = _tail.then((_) => _writeNow(text));
// The caller still sees its own error via [result]; the chain ignores it.
_tail = result.then((_) {}, onError: (Object _) {});
return result;
}
Future<void> _writeNow(String text) async {
Future<void> write(String text) async {
await _file.parent.create(recursive: true);
final tmp = File('${_file.path}.$pid.tmp');
await tmp.writeAsString(text);

View File

@ -1,26 +0,0 @@
/// Compile-time sandbox namespace for the Realtime Database.
///
/// Build a test app with `--dart-define=SYNC_NAMESPACE=sandbox/slot3` and every
/// RTDB path the library reads or writes moves under `/sandbox/slot3/`, so a
/// test build can never touch real data. Sign-in is unchanged (real account).
/// Empty, the default, leaves every path exactly as it was.
library;
import 'firebase_config.dart';
/// The `SYNC_NAMESPACE` dart-define; empty means production paths.
const String syncNamespace = String.fromEnvironment('SYNC_NAMESPACE');
final RegExp _allowed = RegExp(r'^sandbox/[a-z0-9_-]+$');
/// Returns [namespace] when it is empty or `sandbox/<slot>`, else throws.
///
/// Fails closed: a typo must stop the app at startup, never quietly fall back
/// to production paths. A trailing newline is rejected too.
String checkSyncNamespace(String namespace) {
if (namespace.isEmpty || _allowed.hasMatch(namespace)) return namespace;
throw ConfigException(
'SYNC_NAMESPACE "$namespace" is invalid: it must be empty or match '
r'^sandbox/[a-z0-9_-]+$ (e.g. sandbox/slot3)',
);
}

View File

@ -2,7 +2,7 @@ name: crdt_sync
description: >-
Shared CRDT merge scheme + GitHub-Contents-API sync transport for
cross-device apps. Pure Dart, no Flutter dependency.
version: 0.12.1
version: 0.11.3
publish_to: 'none'
environment:

View File

@ -128,32 +128,5 @@ void main() {
await store.write('{"hello":"world"}');
expect(await store.read(), '{"hello":"world"}');
});
test('50 overlapping writes all succeed and the last issued wins',
() async {
// Regression: every call shared one `<file>.<pid>.tmp`, so the first
// rename moved it away and a later rename threw PathNotFoundException.
final store = FileLogPersistence(File('${dir.path}/log.json'));
await Future.wait([
for (var i = 0; i < 50; i++) store.write('{"n":$i}'),
]);
expect(await store.read(), '{"n":49}');
expect(dir.listSync().map((e) => e.uri.pathSegments.last),
['log.json']);
});
test('a failed write rejects its caller but does not block later writes',
() async {
final target = File('${dir.path}/log.json');
// A directory at the target path makes the rename fail.
Directory(target.path).createSync();
final store = FileLogPersistence(target);
await expectLater(
store.write('{"n":0}'), throwsA(isA<FileSystemException>()));
Directory(target.path).deleteSync();
await store.write('{"n":1}');
expect(await store.read(), '{"n":1}');
});
});
}

View File

@ -1,170 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:test/test.dart';
const _databaseUrl = 'https://example-default-rtdb.firebasedatabase.app';
FirebaseTokenProvider _auth() => FirebaseTokenProvider(
apiKey: 'fake-api-key',
store: InMemoryCredentialStore(
FirebaseCredentials(
idToken: 'id-token',
refreshToken: 'refresh-token',
expiresAt: DateTime.utc(2099),
),
),
httpClient: http_testing.MockClient(
(_) async => throw StateError('auth must not be contacted'),
),
);
/// Client over an in-memory transport that records every request path.
(FirebaseRestClient, List<http.Request>) _client(String? namespace) {
final requests = <http.Request>[];
final mock = http_testing.MockClient((request) async {
requests.add(request);
return http.Response(jsonEncode(request.method == 'GET' ? null : 'x'), 200);
});
final client = namespace == null
? FirebaseRestClient(
databaseUrl: _databaseUrl,
auth: _auth(),
httpClient: mock,
)
: FirebaseRestClient(
databaseUrl: _databaseUrl,
auth: _auth(),
httpClient: mock,
namespace: namespace,
);
return (client, requests);
}
void main() {
group('compile-time default', () {
test('is empty when SYNC_NAMESPACE is not defined', () {
expect(syncNamespace, isEmpty);
});
test('empty namespace leaves paths byte-for-byte unchanged', () async {
final (client, requests) = _client(null);
await client.getFileText('devices/pc/log.json');
expect(requests.single.url.path, '/devices/pc/log~2Ejson.json');
});
});
group('prefixing', () {
test('every operation lands under the namespace', () async {
final (client, requests) = _client('sandbox/slot3');
await client.getFileText('devices/pc/log.json');
await client.putFileText('devices/pc/log.json', 't', message: 'm');
await client.listDirectory('devices');
await client.getStringMap('revs');
await client.patchValues('revs', {'a': 'b'});
await client.deleteFile('devices/pc/log.json');
await client.canAccessRemote();
expect(requests.map((r) => r.url.path), [
'/sandbox/slot3/devices/pc/log~2Ejson.json',
'/sandbox/slot3/devices/pc/log~2Ejson.json',
'/sandbox/slot3/devices.json',
'/sandbox/slot3/revs.json',
'/sandbox/slot3/revs.json',
'/sandbox/slot3/devices/pc/log~2Ejson.json',
'/sandbox/slot3.json',
]);
});
test('an explicitly empty namespace is the unprefixed path', () async {
final (client, requests) = _client('');
await client.getStringMap('revs');
expect(requests.single.url.path, '/revs.json');
});
});
group('validation (fail closed)', () {
const bad = [
'sandbox',
'sandbox/',
'sandbox/Slot3',
'sandbox/slot 3',
'sandbox/slot3/',
'sandbox/a/b',
'/sandbox/slot3',
'prod/slot3',
'sandbox/slot3\n',
'Sandbox/slot3',
'sandbox/slot.3',
'sandbox/..',
];
for (final value in bad) {
test('rejects ${jsonEncode(value)}', () {
expect(
() => checkSyncNamespace(value),
throwsA(isA<ConfigException>()),
);
expect(
() => FirebaseRestClient(
databaseUrl: _databaseUrl,
auth: _auth(),
namespace: value,
),
throwsA(isA<ConfigException>()),
);
});
}
test('the message names the define and the required shape', () {
expect(
() => checkSyncNamespace('typo'),
throwsA(
isA<ConfigException>().having(
(e) => e.message,
'message',
allOf(
contains('SYNC_NAMESPACE'),
contains(r'^sandbox/[a-z0-9_-]+$'),
),
),
),
);
});
test('accepts the allowed alphabet', () {
expect(() => checkSyncNamespace('sandbox/slot3'), returnsNormally);
expect(() => checkSyncNamespace('sandbox/a_b-c9'), returnsNormally);
expect(() => checkSyncNamespace(''), returnsNormally);
});
test(
'a bad compile-time define stops firebaseClientFor at startup',
() async {
// The define is compile-time, so only a fresh process can set it.
final result = await Process.run(Platform.resolvedExecutable, [
'-DSYNC_NAMESPACE=sandbox/Typo',
'run',
'tool/namespace_probe.dart',
]);
expect(result.exitCode, isNot(0));
expect(
result.stderr,
contains('SYNC_NAMESPACE "sandbox/Typo" is invalid'),
);
},
);
test('a good compile-time define reaches the request path', () async {
final result = await Process.run(Platform.resolvedExecutable, [
'-DSYNC_NAMESPACE=sandbox/slot3',
'run',
'tool/namespace_probe.dart',
]);
expect(result.stderr, isEmpty);
expect(result.stdout, '/sandbox/slot3/devices.json\n');
});
});
}

View File

@ -1,31 +0,0 @@
// Helper for test/sync_namespace_test.dart: the SYNC_NAMESPACE define is
// compile-time, so the test runs this in a fresh process with -D set. It signs
// in from an in-memory session (no network) and prints the first request path.
import 'package:crdt_sync/crdt_sync.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
Future<void> main() async {
final mock = http_testing.MockClient((request) async {
print(request.url.path);
return http.Response('null', 200);
});
final client = await firebaseClientFor(
config: const FirebaseConfig(
apiKey: 'k',
databaseUrl: 'https://example.firebasedatabase.app',
projectId: 'p',
uid: 'u',
email: 'e@x.y',
),
store: InMemoryCredentialStore(
FirebaseCredentials(
idToken: 'i',
refreshToken: 'r',
expiresAt: DateTime.utc(2099),
),
),
httpClient: mock,
);
await client.listDirectory('devices');
}

View File

@ -8,7 +8,7 @@ bootstrap, so a new app reaches "syncing" without writing any of it.
`crdt_sync` is pure Dart on purpose — it runs from command-line tools and from
`wake_alarm`'s headless systemd job, so it cannot depend on
`flutter_secure_storage` or the Android Google plugin. It takes closures instead.
`flutter_secure_storage` or `google_sign_in`. It takes closures instead.
Every Flutter app then wrote the same ~390 lines to supply those closures
(`firebase_backend.dart` + `google_sign_in_backend.dart` + `google_platform*`),
@ -110,11 +110,6 @@ const kServerClientId = '845446124781-...apps.googleusercontent.com';
/// to loopback.
const kDesktopClientId = '845446124781-...apps.googleusercontent.com';
/// That Desktop client's secret. Google rejects the token exchange without
/// it, but treats it as non-confidential for installed apps: inject it at
/// build time (`--dart-define`), never commit it. '' sends none.
const kDesktopClientSecret = String.fromEnvironment('DESKTOP_CLIENT_SECRET');
SyncSettingsScreen(
accountLoader: () => loadAccount(),
accountSaver: saveAccount,
@ -126,7 +121,6 @@ SyncSettingsScreen(
tokenFetcher: () => googleAnyIdToken(
serverClientId: kServerClientId,
desktopClientId: kDesktopClientId,
desktopClientSecret: kDesktopClientSecret,
),
),
googleAvailable: googleAnySignInSupported(
@ -138,13 +132,13 @@ SyncSettingsScreen(
### One tap on every platform
[`google_account_token`](../google_account_token) wraps Android's AccountManager
(no Google client library in the APK), so a real GTK desktop build cannot use
it. This package covers both halves and picks between them:
`google_sign_in` ships **android, ios and web** implementations only — there
is no `google_sign_in_linux` — so a real GTK desktop build cannot use the
plugin at all. This package covers both halves and picks between them:
| Platform | Flow | Entry point |
|---|---|---|
| Android | `google_account_token` plugin, OS account chooser | `googleIdToken` |
| Android / iOS | `google_sign_in` plugin, OS account picker | `googleIdToken` |
| Linux / Windows / macOS | OAuth **loopback** + PKCE, system browser | `googleDesktopIdToken` |
| Web | none (GIS renders its own button) | falls back to password |

View File

@ -84,9 +84,9 @@ Future<FirebaseRestClient?> openSync(
/// Signs in with Google alone, for a device with no account stored yet.
///
/// The one-tap path: [openSync] needs an account to know which email to use,
/// but a fresh install has none. [tokenFetcher] is the app's Google token
/// source -- a closure because the Android plugin does not exist elsewhere and
/// this package must keep working on Linux desktop.
/// but a fresh install has none. [tokenFetcher] is the app's `google_sign_in`
/// call -- a closure because that plugin is Android/iOS/web only and this
/// package must keep working on Linux desktop.
///
/// Returns null when the user dismisses the picker; throws
/// [FirebaseAuthError] when Google succeeds but resolves to the wrong uid,
@ -171,5 +171,8 @@ Future<FirebaseRestClient?> _clientFromStoredSession(
store: keystoreCredentialStore(storage: storage),
);
if (!await auth.hasSession()) return null;
return FirebaseRestClient(databaseUrl: app.project.databaseUrl, auth: auth);
return FirebaseRestClient(
databaseUrl: app.project.databaseUrl,
auth: auth,
);
}

View File

@ -1,8 +1,8 @@
/// Picks the right Google sign-in flow for the platform.
///
/// Two flows exist because one is not enough: the `google_account_token`
/// plugin wraps Android's AccountManager, which has no desktop counterpart, so
/// a real GTK desktop build reaches Google through the OAuth loopback flow
/// Two flows exist because one is not enough: `google_sign_in` ships android,
/// ios and web implementations only -- there is no `google_sign_in_linux` --
/// so a real GTK desktop build reaches Google through the OAuth loopback flow
/// instead. Both yield a Google ID token of the same shape, so
/// `signInWithGoogle` cannot tell them apart.
///
@ -48,7 +48,7 @@ bool desktopSignInSupported(String desktopClientId) =>
/// worth testing, and returning it lets the suite assert the routing on a
/// host that can run neither flow for real.
enum GoogleFlow {
/// The `google_account_token` plugin: Android.
/// The `google_sign_in` plugin: Android and iOS.
plugin,
/// OAuth loopback in the system browser: Linux, Windows, macOS.
@ -72,13 +72,9 @@ GoogleFlow googleFlowFor({
///
/// Null when neither is available or the user backs out -- the caller falls
/// back to the password path either way.
///
/// [desktopClientSecret] is the Desktop client's secret (see
/// `googleDesktopIdToken`); '' sends none.
Future<String?> googleAnyIdToken({
required String serverClientId,
required String desktopClientId,
String desktopClientSecret = '',
}) async {
switch (googleFlowFor(
serverClientId: serverClientId,
@ -91,10 +87,7 @@ Future<String?> googleAnyIdToken({
case GoogleFlow.plugin:
return await googleIdToken(serverClientId: serverClientId);
case GoogleFlow.desktop:
return await googleDesktopIdToken(
clientId: desktopClientId,
clientSecret: desktopClientSecret,
);
return await googleDesktopIdToken(clientId: desktopClientId);
// coverage:ignore-end
case GoogleFlow.none:
return null;

View File

@ -1,11 +1,11 @@
/// One-tap Google sign-in for desktop, via the OAuth loopback flow.
///
/// The Android plugin (`google_account_token`) wraps AccountManager, which
/// does not exist on Linux, so a real GTK build cannot use it at all. The
/// installed-app flow replaces it: bind a loopback socket, open the system
/// browser at Google's consent screen, catch the redirect, and exchange the
/// code for an id_token with PKCE. The token is the same shape the plugin
/// returns, so `signInWithGoogle` cannot tell the two apart.
/// `google_sign_in` ships android, ios and web implementations only -- there
/// is no `google_sign_in_linux` -- so a real GTK build cannot use the plugin
/// at all. The installed-app flow replaces it: bind a loopback socket, open
/// the system browser at Google's consent screen, catch the redirect, and
/// exchange the code for an id_token with PKCE. The token is the same shape
/// the plugin returns, so `signInWithGoogle` cannot tell the two apart.
///
/// Everything decision-shaped lives in `google_pkce.dart` and is covered.
/// This file is the I/O: a socket, a browser, one POST.
@ -66,12 +66,8 @@ Future<bool> _launchInBrowser(Uri url) =>
/// [clientId] must be a **Desktop**-type OAuth client. The Web client id used
/// for the Android flow will be rejected here, and a Desktop client is the
/// only type permitted to redirect to loopback.
///
/// [clientSecret] is that client's secret; Google's token endpoint rejects a
/// Desktop exchange without it. See `tokenExchangeBody`.
Future<String?> googleDesktopIdToken({
required String clientId,
String clientSecret = '',
ServerBinder binder = _bindLoopback,
BrowserLauncher launcher = _launchInBrowser,
http.Client? httpClient,
@ -87,7 +83,11 @@ Future<String?> googleDesktopIdToken({
final client = httpClient ?? http.Client();
try {
final opened = await launcher(
buildAuthUrl(clientId: clientId, redirectPort: port, pkce: pair),
buildAuthUrl(
clientId: clientId,
redirectPort: port,
pkce: pair,
),
);
// No browser means no redirect will ever arrive: fail now rather than
// holding the socket open for the full timeout.
@ -99,7 +99,6 @@ Future<String?> googleDesktopIdToken({
return await _exchange(
client: client,
clientId: clientId,
clientSecret: clientSecret,
code: code,
port: port,
verifier: pair.verifier,
@ -137,7 +136,6 @@ Future<String?> _awaitRedirect(
Future<String?> _exchange({
required http.Client client,
required String clientId,
required String clientSecret,
required String code,
required int port,
required String verifier,
@ -146,7 +144,6 @@ Future<String?> _exchange({
Uri.parse(kGoogleTokenEndpoint),
body: tokenExchangeBody(
clientId: clientId,
clientSecret: clientSecret,
code: code,
redirectPort: port,
verifier: verifier,

View File

@ -136,21 +136,13 @@ RedirectResult parseRedirect(Uri uri, {required String expectedState}) {
}
/// Form body for exchanging [code] at [kGoogleTokenEndpoint].
///
/// [clientSecret] is the Desktop client's secret. Google issues Desktop
/// clients one and rejects the exchange without it even with PKCE, but
/// documents it as non-confidential for installed apps: it ships in the
/// binary, injected at build time and never committed. Left out of the body
/// when empty, so a caller that has none behaves exactly as before.
Map<String, String> tokenExchangeBody({
required String clientId,
required String code,
required int redirectPort,
required String verifier,
String clientSecret = '',
}) => <String, String>{
'client_id': clientId,
if (clientSecret.isNotEmpty) 'client_secret': clientSecret,
'code': code,
'code_verifier': verifier,
'grant_type': 'authorization_code',

View File

@ -1,8 +1,10 @@
/// Platform gate for the programmatic Google sign-in flow.
///
/// Conditional export because the answer differs per platform: the io half
/// defers to the `google_account_token` plugin (Android only), the web half is
/// a constant false.
/// Conditional export because the answer differs per platform *and* because
/// asking the plugin directly is unsafe: `supportsAuthenticate()` throws
/// `UnimplementedError` where no implementation is registered -- an `Error`,
/// not an `Exception`, so it escapes an ordinary catch and would take down
/// the settings screen's `build()`.
library;
export 'google_platform_io.dart'

View File

@ -1,18 +1,15 @@
/// Whether the programmatic Google sign-in flow exists on this platform.
///
/// The io half. The flow is the `google_account_token` plugin, which wraps the
/// framework AccountManager and therefore exists on Android only. Every other
/// `dart:io` platform (Linux, Windows, macOS) reaches Google through the
/// loopback flow instead.
/// The io half. `google_sign_in` ships federated implementations for android,
/// ios and web only -- there is no `google_sign_in_linux` -- so a real GTK
/// desktop build (lyricanki has one) answers false here and reaches Google
/// through the loopback flow instead.
library;
import 'package:google_account_token/google_account_token.dart';
import 'dart:io';
/// True where the one-tap AccountManager flow exists: Android.
///
/// Asks the plugin rather than `Platform.isAndroid` so the two can never
/// disagree about where a token can be produced.
bool get googleSignInSupported => googleAccountTokenSupported;
/// True on the platforms shipping the programmatic plugin flow.
bool get googleSignInSupported => Platform.isAndroid || Platform.isIOS;
/// Whether the loopback sign-in flow can run: it needs a bindable socket and
/// a system browser, which every `dart:io` platform has.

View File

@ -1,8 +1,8 @@
/// One-tap Google sign-in: the `google_account_token` plugin call, owned once.
/// One-tap Google sign-in: the `google_sign_in` plugin call, owned once.
///
/// Why it lives here and not in `crdt_sync`: that package is pure Dart on
/// purpose, because the same library runs on Linux desktop and headless under
/// systemd, where the Android plugin does not exist. It only ever receives a
/// systemd, where `google_sign_in` does not exist. It only ever receives a
/// token string through a closure -- see `signInWithGoogle`, which takes a
/// `tokenFetcher`. This file is that closure.
///
@ -11,8 +11,10 @@
/// time is the mistake this package exists to prevent.
library;
import 'dart:developer';
import 'package:crdt_sync_flutter/src/google_platform.dart' as platform_gate;
import 'package:google_account_token/google_account_token.dart' as account;
import 'package:google_sign_in/google_sign_in.dart';
/// Whether this build can sign in with Google programmatically.
///
@ -31,21 +33,56 @@ bool googleSignInSupported(String serverClientId) =>
/// Returns a Google ID token for the signed-in account, or null.
///
/// Null rather than throwing when the user dismisses the chooser: cancelling
/// is an ordinary outcome, and the caller falls back to the password path.
/// The plugin logs (level 900) every failure, which matters because a
/// *configuration* failure (unregistered SHA-1, wrong client id) arrives
/// through the same path as "the user changed their mind".
/// Null rather than throwing when the user dismisses the picker: cancelling is
/// an ordinary outcome, and the caller falls back to the password path.
///
/// [serverClientId] must be the project's **Web** OAuth client id -- the token
/// is minted for that audience, and an Android client id yields one Firebase
/// rejects with `audience mismatch`.
/// [serverClientId] must be the project's **Web** OAuth client id -- Android
/// must request a token minted for the web client, and an Android client id
/// yields a token Firebase rejects with `audience mismatch`.
///
/// [signInFn] exists for tests; when supplied it replaces the plugin entirely.
/// [signInFn] exists for tests, which cannot reach the plugin's platform
/// channel; when supplied it replaces the plugin entirely.
Future<String?> googleIdToken({
required String serverClientId,
Future<String?> Function()? signInFn,
}) async {
if (signInFn != null) return await signInFn();
return await account.googleIdToken(serverClientId: serverClientId);
if (serverClientId.isEmpty) {
log(
'Google sign-in unavailable: no server client id was compiled in; '
'falling back to the password path',
level: 900,
);
return null;
}
// The plugin reaches the OS account picker through a platform channel,
// which `flutter test` has no binding for. Everything above this point is
// pure Dart and is covered.
// coverage:ignore-start
try {
await GoogleSignIn.instance.initialize(serverClientId: serverClientId);
if (!GoogleSignIn.instance.supportsAuthenticate()) {
log(
'Google sign-in is not available on this platform; falling back to '
'the password path',
level: 900,
);
return null;
}
final account = await GoogleSignIn.instance.authenticate();
return account.authentication.idToken;
} on GoogleSignInException catch (error, stackTrace) {
// Includes the user simply dismissing the picker, which is not an error
// worth surfacing -- but log it, because a *configuration* failure
// (unregistered SHA-1, wrong client id) arrives through the same path and
// is otherwise indistinguishable from "the user changed their mind".
log(
'Google sign-in did not complete',
level: 900,
error: error,
stackTrace: stackTrace,
);
return null;
}
// coverage:ignore-end
}

View File

@ -61,11 +61,11 @@ packages:
dependency: "direct main"
description:
path: crdt_sync_dart
ref: "crdt_sync_dart-v0.12.0"
resolved-ref: "59b33949e4a130bb76c77e7434ddf4bbf8cde3a3"
ref: "crdt_sync_dart-v0.11.3"
resolved-ref: a2e7334dfec5131170a084510d70361d53dc40bc
url: "https://github.com/kuhyx/utils"
source: git
version: "0.12.0"
version: "0.11.3"
crypto:
dependency: "direct main"
description:
@ -177,15 +177,54 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
google_account_token:
google_identity_services_web:
dependency: transitive
description:
name: google_identity_services_web
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
url: "https://pub.dev"
source: hosted
version: "0.3.3+1"
google_sign_in:
dependency: "direct main"
description:
path: google_account_token
ref: "google_account_token-v0.1.0"
resolved-ref: db70ba204d124c6eadf9b8808cd3d3b3e70363d1
url: "https://github.com/kuhyx/utils"
source: git
version: "0.1.0"
name: google_sign_in
sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
google_sign_in_android:
dependency: transitive
description:
name: google_sign_in_android
sha256: "57782125965ca87b03d42a147d40b046f1fd6a09141a58913e1b86671a5ef22e"
url: "https://pub.dev"
source: hosted
version: "7.2.16"
google_sign_in_ios:
dependency: transitive
description:
name: google_sign_in_ios
sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950
url: "https://pub.dev"
source: hosted
version: "6.3.0"
google_sign_in_platform_interface:
dependency: transitive
description:
name: google_sign_in_platform_interface
sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
google_sign_in_web:
dependency: transitive
description:
name: google_sign_in_web
sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14
url: "https://pub.dev"
source: hosted
version: "1.1.3"
hooks:
dependency: transitive
description:

View File

@ -4,7 +4,7 @@ description: >-
persisted per-install device id, and a one-call bootstrap. Removes the
~150 lines of firebase_backend.dart every consuming app was copying.
publish_to: 'none'
version: 0.5.1
version: 0.3.5
environment:
sdk: ^3.12.2
@ -13,7 +13,7 @@ dependencies:
crdt_sync:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.12.1
ref: crdt_sync_dart-v0.11.3
path: crdt_sync_dart
crypto: 3.0.8
flutter:
@ -23,15 +23,10 @@ dependencies:
# consuming apps can test their own sync wiring without rebuilding it.
flutter_test:
sdk: flutter
# The one-tap Google ID token on Android: AccountManager chooser, no Google
# client library in the APK. Android only -- web has no token source and
# desktop reaches Google through the loopback flow in
# The one-tap Google plugin. Android/iOS/web only -- there is no Linux
# implementation -- so desktop reaches Google through the loopback flow in
# google_desktop_sign_in.dart instead. See google_platform.dart.
google_account_token:
git:
url: https://github.com/kuhyx/utils
ref: google_account_token-v0.1.0
path: google_account_token
google_sign_in: 7.2.0
http: 1.6.0
shared_preferences: 2.5.6
# Opens the system browser for the desktop loopback sign-in.

View File

@ -1,23 +1,11 @@
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:google_account_token/google_account_token.dart'
show kGoogleAccountTokenChannel;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
// `flutter test` reports Android as the default target platform, so the
// desktop configuration this suite exercises is pinned explicitly: the
// plugin half is false and the loopback half is live. The Android routing
// has its own group at the bottom.
setUp(() => debugDefaultTargetPlatformOverride = TargetPlatform.linux);
tearDown(() {
debugDefaultTargetPlatformOverride = null;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(kGoogleAccountTokenChannel, null);
});
// Under `flutter test` the io platform gate answers for the host, which is
// Linux -- so the plugin half is false and the loopback half is live. That
// is the desktop configuration, and it is the one this suite can exercise
// honestly; the Android half is asserted on the device instead.
group('desktopSignInSupported', () {
test('is true on this host once a desktop client id exists', () {
expect(desktopSignInSupported('desktop-cid'), isTrue);
@ -102,35 +90,4 @@ void main() {
);
});
});
group('on Android', () {
setUp(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
test('the plugin flow wins and the desktop one is off', () {
expect(
googleFlowFor(serverClientId: 'web-cid', desktopClientId: 'd'),
GoogleFlow.plugin,
);
expect(desktopSignInSupported('d'), isFalse);
expect(
googleAnySignInSupported(
serverClientId: 'web-cid',
desktopClientId: '',
),
isTrue,
);
});
test('googleAnyIdToken reaches the AccountManager plugin', () async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
kGoogleAccountTokenChannel,
(_) async => 'native-token',
);
expect(
await googleAnyIdToken(serverClientId: 'web-cid', desktopClientId: ''),
'native-token',
);
});
});
}

View File

@ -23,9 +23,9 @@ BrowserLauncher _respondingLauncher(
final state = url.queryParameters['state']!;
unawaited(
http.get(
Uri.parse(
'http://127.0.0.1:$port/',
).replace(queryParameters: params(state)),
Uri.parse('http://127.0.0.1:$port/').replace(
queryParameters: params(state),
),
),
);
return true;
@ -54,22 +54,6 @@ void main() {
expect(sentBody['code_verifier'], 'v');
});
test('sends the configured client_secret in the exchange', () async {
late Map<String, String> sentBody;
final client = MockClient((request) async {
sentBody = Uri.splitQueryString(request.body);
return http.Response('{"id_token":"t"}', 200);
});
await googleDesktopIdToken(
clientId: 'desktop-cid',
clientSecret: 'shh',
binder: _bind,
launcher: _respondingLauncher((state) => {'code': 'c', 'state': state}),
httpClient: client,
);
expect(sentBody['client_secret'], 'shh');
});
test('returns null when the user denies consent', () async {
final token = await googleDesktopIdToken(
clientId: 'cid',
@ -115,7 +99,9 @@ void main() {
final token = await googleDesktopIdToken(
clientId: 'cid',
binder: _bind,
launcher: _respondingLauncher((state) => {'code': 'c', 'state': state}),
launcher: _respondingLauncher(
(state) => {'code': 'c', 'state': state},
),
httpClient: MockClient(
(_) async => http.Response('{"error":"invalid_grant"}', 400),
),

View File

@ -142,29 +142,6 @@ void main() {
expect(body['redirect_uri'], 'http://127.0.0.1:9');
});
test('tokenExchangeBody omits client_secret when none is configured', () {
final body = tokenExchangeBody(
clientId: 'cid',
code: 'c',
redirectPort: 9,
verifier: 'v',
);
expect(body.containsKey('client_secret'), isFalse);
});
test('tokenExchangeBody sends the Desktop client_secret when given', () {
// Google issues Desktop clients a secret and rejects the exchange
// without it, even with PKCE.
final body = tokenExchangeBody(
clientId: 'cid',
clientSecret: 'the-secret',
code: 'c',
redirectPort: 9,
verifier: 'v',
);
expect(body['client_secret'], 'the-secret');
});
group('idTokenFromResponse', () {
test('reads the id_token', () {
expect(idTokenFromResponse('{"id_token":"tok"}'), 'tok');

View File

@ -1,39 +1,18 @@
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:google_account_token/google_account_token.dart'
show kGoogleAccountTokenChannel;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
tearDown(() {
debugDefaultTargetPlatformOverride = null;
messenger.setMockMethodCallHandler(kGoogleAccountTokenChannel, null);
});
group('googleSignInSupported', () {
test('is true on Android once a client id is present', () {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
expect(googleSignInSupported('some-client-id'), isTrue);
});
test('is false on iOS: AccountManager is Android-only', () {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
expect(googleSignInSupported('some-client-id'), isFalse);
});
test('is false without a client id, whatever the platform', () {
// The guard that stops a build which forgot the constant from showing a
// button that can only ever report "cancelled".
expect(googleSignInSupported(''), isFalse);
});
test('is false on a desktop host even with a client id', () {
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
test('follows the platform gate once a client id is present', () {
// Under `flutter test` the io half answers for the host, which is not
// Android -- so this is false here and true on a real handset. Asserting
// the platform's own answer keeps the test honest on every host.
expect(googleSignInSupported('some-client-id'), isFalse);
});
});
@ -61,30 +40,4 @@ void main() {
expect(await googleIdToken(serverClientId: ''), isNull);
});
});
group('googleIdToken through the AccountManager plugin', () {
setUp(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
test('asks the native side for a token for the web client', () async {
MethodCall? seen;
messenger.setMockMethodCallHandler(kGoogleAccountTokenChannel, (
call,
) async {
seen = call;
return 'native-token';
});
final token = await googleIdToken(serverClientId: 'web-cid');
expect(token, 'native-token');
expect(seen?.method, 'idToken');
expect(seen?.arguments, {'serverClientId': 'web-cid'});
});
test('a failing native side is null, not a throw', () async {
messenger.setMockMethodCallHandler(
kGoogleAccountTokenChannel,
(_) async => throw PlatformException(code: 'auth_failed'),
);
expect(await googleIdToken(serverClientId: 'web-cid'), isNull);
});
});
}

View File

@ -1,6 +0,0 @@
.dart_tool/
.packages
build/
pubspec.lock
coverage/
*.iml

View File

@ -1,66 +0,0 @@
# Pre-commit Configuration for data_transfer_flutter
# Install: pre-commit install && pre-commit install --hook-type pre-push
# Run: pre-commit run --all-files
# NOTE: this subproject lives inside the ~/src/utils monorepo, whose git root is
# one level up. pre-commit always runs hooks from the git root, so every path
# below is written relative to THAT, not to this directory, and the top-level
# `files` key keeps these hooks off the sibling subprojects. CI invokes this
# config exactly the same way:
# pre-commit run --config data_transfer_flutter/.pre-commit-config.yaml --all-files
files: ^data_transfer_flutter/
default_stages: [pre-commit]
fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- id: end-of-file-fixer
- id: check-yaml
- id: check-merge-conflict
- id: mixed-line-ending
args: [--fix=lf]
- repo: local
hooks:
- id: file-length-cap
name: file length <= 250 lines
entry: bash scripts/check_file_length.sh
language: system
pass_filenames: true
- id: subproject-scope
name: subproject scope regex still matches
entry: scripts/check_subproject_scope.sh data_transfer_flutter
language: script
# always_run so the top-level `files` filter cannot skip the one hook
# whose job is to notice that filter has stopped matching anything.
always_run: true
pass_filenames: false
- id: no-crdt-sync-override
name: no local dependency_overrides on a utils git dependency
entry: scripts/check_no_crdt_sync_override.sh data_transfer_flutter/pubspec.yaml
language: script
pass_filenames: false
- id: dart-format
name: dart format --set-exit-if-changed
entry: bash -c 'cd data_transfer_flutter && dart format --set-exit-if-changed lib test'
language: system
pass_filenames: false
- id: flutter-analyze
name: flutter analyze
entry: bash -c 'cd data_transfer_flutter && flutter analyze'
language: system
pass_filenames: false
- id: flutter-test-coverage
name: flutter test --coverage with 100% line-coverage gate
entry: >-
bash -c 'cd data_transfer_flutter && flutter test --coverage &&
scripts/check_coverage.sh coverage/lcov.info 100'
language: system
pass_filenames: false
require_serial: true
stages: [pre-push]

View File

@ -1,38 +0,0 @@
# data_transfer_flutter
Shared "export to a JSON file / import from a JSON file" plumbing for kuhy's
Flutter apps. Apps keep their own export CONTENT; this owns the file plumbing
and the envelope.
## Install
```yaml
dependencies:
data_transfer_flutter:
git:
url: https://github.com/kuhyx/utils
ref: data_transfer_flutter-v0.2.1
path: data_transfer_flutter
```
## Use
```dart
final saved = await exportJson(fileName: 'x.json', json: envelope);
final map = await pickJson(); // null when cancelled
```
- `exportJson` / `exportJsonText` save to **device storage**: Android and iOS
use the system "Save as" document picker (`file_picker` `saveFile`, i.e.
ACTION_CREATE_DOCUMENT), desktop a save dialog (`file_selector`), web a
browser download. Returns false when cancelled.
- `exportTextFile` does the same for any text (`text/csv`, `text/calendar`).
- `shareJson` is an optional second action (share sheet). It is never the
only way out: a stock phone's share sheet has no local save target.
- `pickJson` / `pickJsonText` use the system open-document picker.
- `buildEnvelope` / `validateEnvelope` / `parseEnvelope` write and check
`{format, version, exported_at, ...}`; mismatches throw a typed
`EnvelopeException` (always a `FormatException`).
Tests fake `DataTransfer.platform` (a `DataTransferPlatform`); restore it in
`tearDown`.

View File

@ -1,38 +0,0 @@
include: package:very_good_analysis/analysis_options.yaml
analyzer:
language:
strict-casts: true
strict-inference: true
strict-raw-types: true
errors:
# Promote key lints to errors so they fail CI, not just warn.
missing_required_param: error
unnecessary_null_comparison: error
dead_code: error
invalid_annotation_target: error
exclude:
- build/**
- "**/*.g.dart"
- "**/*.freezed.dart"
- "test/**"
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
linter:
rules:
# very_good_analysis enables most rules; add extras it doesn't include.
always_use_package_imports: true
avoid_print: true
avoid_relative_lib_imports: true
cancel_subscriptions: true
close_sinks: true
comment_references: false # conflicts with typical Flutter API docs style
directives_ordering: true
lines_longer_than_80_chars: true
public_member_api_docs: true
unawaited_futures: true

View File

@ -1,7 +0,0 @@
/// Shared JSON export/import plumbing: save, share, pick and a versioned
/// envelope.
library;
export 'src/envelope.dart';
export 'src/platform.dart';
export 'src/transfer.dart';

View File

@ -1,118 +0,0 @@
import 'dart:convert';
/// Why a file is not an acceptable export envelope.
///
/// Always a [FormatException], so callers that only care about "bad file"
/// can keep catching that.
sealed class EnvelopeException implements FormatException {
const EnvelopeException(this.message);
@override
final String message;
@override
dynamic get source => null;
@override
int? get offset => null;
@override
String toString() => 'EnvelopeException: $message';
}
/// The text is not JSON at all.
final class EnvelopeInvalidJson extends EnvelopeException {
/// Creates the error.
const EnvelopeInvalidJson() : super('not valid JSON');
}
/// The JSON is valid but its top level is not an object.
final class EnvelopeNotObject extends EnvelopeException {
/// Creates the error.
const EnvelopeNotObject() : super('export is not a JSON object');
}
/// The `format` marker is missing or names another kind of file.
final class EnvelopeFormatMismatch extends EnvelopeException {
/// Creates the error for a file marked [actual] when [expected] was needed.
EnvelopeFormatMismatch({required this.expected, required this.actual})
: super('not a $expected file (found "$actual")');
/// The format the caller accepts.
final String expected;
/// The format the file declared, `null` when it declared none.
final Object? actual;
}
/// The `version` is missing or differs from the one the caller reads.
final class EnvelopeVersionMismatch extends EnvelopeException {
/// Creates the error for a file at version [actual] when [expected] was
/// needed.
EnvelopeVersionMismatch({required this.expected, required this.actual})
: super('unsupported version $actual (expected $expected)');
/// The version the caller reads.
final int expected;
/// The version the file declared, `null` when it declared none.
final Object? actual;
}
/// Builds `{format, version, exported_at, ...body}`, in that key order.
///
/// [now] is converted to UTC. Throws [ArgumentError] when [body] reuses an
/// envelope key, which would silently corrupt the marker.
Map<String, Object?> buildEnvelope({
required String format,
required int version,
required DateTime now,
Map<String, Object?> body = const {},
}) {
const reserved = {'format', 'version', 'exported_at'};
final clash = body.keys.where(reserved.contains).toList();
if (clash.isNotEmpty) {
throw ArgumentError.value(body, 'body', 'reuses envelope keys: $clash');
}
return <String, Object?>{
'format': format,
'version': version,
'exported_at': now.toUtc().toIso8601String(),
...body,
};
}
/// Returns [decoded] when it is an object marked [format] at [version].
///
/// Throws an [EnvelopeException] subtype otherwise.
Map<String, Object?> validateEnvelope(
Object? decoded, {
required String format,
required int version,
}) {
if (decoded is! Map<String, Object?>) throw const EnvelopeNotObject();
final actualFormat = decoded['format'];
if (actualFormat != format) {
throw EnvelopeFormatMismatch(expected: format, actual: actualFormat);
}
final actualVersion = decoded['version'];
if (actualVersion != version) {
throw EnvelopeVersionMismatch(expected: version, actual: actualVersion);
}
return decoded;
}
/// Decodes [text] and runs [validateEnvelope] on it.
Map<String, Object?> parseEnvelope(
String text, {
required String format,
required int version,
}) {
final Object? decoded;
try {
decoded = jsonDecode(text);
} on FormatException {
throw const EnvelopeInvalidJson();
}
return validateEnvelope(decoded, format: format, version: version);
}

View File

@ -1,27 +0,0 @@
import 'package:file_selector/file_selector.dart';
/// The type group the open and save dialogs restrict to.
const jsonTypeGroup = XTypeGroup(
label: 'JSON',
extensions: ['json'],
// Needed by the macOS/iOS pickers and harmless elsewhere; a group with no
// recognised identifier silently matches nothing on those hosts.
uniformTypeIdentifiers: ['public.json'],
mimeTypes: ['application/json'],
);
/// The save-dialog filter for [fileName] holding [mimeType] data.
///
/// Keyed on the file's own extension so a CSV export is not offered under a
/// JSON filter; a name with no extension accepts any type.
XTypeGroup saveTypeGroup(String fileName, String mimeType) {
final dot = fileName.lastIndexOf('.');
final extension = dot > 0 && dot < fileName.length - 1
? fileName.substring(dot + 1).toLowerCase()
: null;
return XTypeGroup(
label: extension?.toUpperCase(),
extensions: extension == null ? null : [extension],
mimeTypes: [mimeType],
);
}

View File

@ -1,32 +0,0 @@
/// What differs per host: where the bytes of an export go and come from.
///
/// The seam tests fake. Real implementations live in `platform_io.dart`
/// (Android, iOS, desktop) and `platform_web.dart`, chosen by
/// `platform_default.dart` so no `dart:io` import reaches a web build.
abstract interface class DataTransferPlatform {
/// Saves [text] as [fileName] where the user can find it again.
///
/// Android/iOS: the system "Save as" document picker (device storage).
/// Desktop: a save dialog. Web: a browser download. Returns false when the
/// user cancelled.
Future<bool> saveText({
required String fileName,
required String text,
String mimeType = 'application/json',
});
/// Offers [text] as [fileName] to the share sheet, where the host has one.
///
/// A second action only: a share sheet can route the data to cloud apps, so
/// it never replaces [saveText]. Hosts without a sheet fall back to
/// [saveText].
Future<bool> shareText({
required String fileName,
required String text,
String mimeType = 'application/json',
});
/// Opens the system open-document picker and returns the file's text, or
/// null when the user cancelled.
Future<String?> pickText();
}

View File

@ -1,3 +0,0 @@
export 'platform_io.dart'
if (dart.library.js_interop) 'platform_web.dart'
show createDefaultPlatform;

View File

@ -1,120 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:data_transfer_flutter/src/json_type_group.dart';
import 'package:data_transfer_flutter/src/platform.dart';
import 'package:file_picker/file_picker.dart';
import 'package:file_selector/file_selector.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
/// Writes [bytes] through the system "Save as" picker; false on cancel.
typedef DocumentSaver =
Future<bool> Function({
required String fileName,
required Uint8List bytes,
required String mimeType,
});
/// Offers [text] as a file named [fileName] to the share sheet.
typedef SheetSharer =
Future<bool> Function({
required String fileName,
required String text,
required String mimeType,
});
/// The platform for hosts that have `dart:io`.
DataTransferPlatform createDefaultPlatform() => IoDataTransferPlatform();
/// Android, iOS and desktop.
///
/// Mobile saves through the Storage Access Framework / document picker
/// (`file_picker`'s `saveFile`), because the share sheet on a stock phone has
/// no local save target and an export that can only be shared is an upload.
/// Desktop uses `file_selector`'s save dialog. Opening is `file_selector`'s
/// `openFile` everywhere: ACTION_OPEN_DOCUMENT on Android.
class IoDataTransferPlatform implements DataTransferPlatform {
/// Creates the platform; every argument is a test seam.
IoDataTransferPlatform({
bool? isMobile,
DocumentSaver? documentSaver,
SheetSharer? sheetSharer,
}) : _isMobile = isMobile ?? (Platform.isAndroid || Platform.isIOS),
_documentSaver = documentSaver ?? _saveViaDocumentPicker,
_sheetSharer = sheetSharer ?? _shareViaSheet;
final bool _isMobile;
final DocumentSaver _documentSaver;
final SheetSharer _sheetSharer;
@override
Future<bool> saveText({
required String fileName,
required String text,
String mimeType = 'application/json',
}) async {
if (_isMobile) {
return await _documentSaver(
fileName: fileName,
bytes: Uint8List.fromList(utf8.encode(text)),
mimeType: mimeType,
);
}
final location = await getSaveLocation(
suggestedName: fileName,
acceptedTypeGroups: [saveTypeGroup(fileName, mimeType)],
);
if (location == null) return false;
await File(location.path).writeAsString(text);
return true;
}
@override
Future<bool> shareText({
required String fileName,
required String text,
String mimeType = 'application/json',
}) => _isMobile
? _sheetSharer(fileName: fileName, text: text, mimeType: mimeType)
: saveText(fileName: fileName, text: text, mimeType: mimeType);
@override
Future<String?> pickText() async {
final file = await openFile(acceptedTypeGroups: const [jsonTypeGroup]);
return await file?.readAsString();
}
}
// coverage:ignore-start
// Real plugin calls: unreachable on the Linux test host (a platform channel
// answers nothing there) and faked at the typedef seams above instead.
Future<bool> _saveViaDocumentPicker({
required String fileName,
required Uint8List bytes,
required String mimeType,
}) async {
final saved = await FilePicker.saveFile(
fileName: fileName,
bytes: bytes,
mimeType: mimeType,
);
return saved != null;
}
Future<bool> _shareViaSheet({
required String fileName,
required String text,
required String mimeType,
}) async {
final dir = await getTemporaryDirectory();
final file = File(p.join(dir.path, fileName));
await file.writeAsString(text);
await SharePlus.instance.share(
ShareParams(files: [XFile(file.path, mimeType: mimeType)]),
);
return true;
}
// coverage:ignore-end

View File

@ -1,43 +0,0 @@
import 'dart:convert';
import 'package:data_transfer_flutter/src/json_type_group.dart';
import 'package:data_transfer_flutter/src/platform.dart';
import 'package:file_selector/file_selector.dart';
/// The browser's platform: downloads and the open-file dialog.
DataTransferPlatform createDefaultPlatform() => const WebDataTransferPlatform();
/// Browser implementation. No share sheet; download covers both actions.
class WebDataTransferPlatform implements DataTransferPlatform {
/// Creates the platform.
const WebDataTransferPlatform();
@override
Future<bool> saveText({
required String fileName,
required String text,
String mimeType = 'application/json',
}) async {
// `saveTo` on web clicks a synthetic anchor: the path is ignored and the
// browser's download UI decides where the file lands.
await XFile.fromData(
utf8.encode(text),
mimeType: mimeType,
name: fileName,
).saveTo('');
return true;
}
@override
Future<bool> shareText({
required String fileName,
required String text,
String mimeType = 'application/json',
}) => saveText(fileName: fileName, text: text, mimeType: mimeType);
@override
Future<String?> pickText() async {
final file = await openFile(acceptedTypeGroups: const [jsonTypeGroup]);
return await file?.readAsString();
}
}

View File

@ -1,65 +0,0 @@
import 'dart:convert';
import 'package:data_transfer_flutter/src/envelope.dart';
import 'package:data_transfer_flutter/src/platform.dart';
import 'package:data_transfer_flutter/src/platform_default.dart';
/// Holds the platform the top-level functions use; tests replace it.
abstract final class DataTransfer {
/// The active platform. Defaults to the host's real implementation.
static DataTransferPlatform platform = createDefaultPlatform();
}
const _pretty = JsonEncoder.withIndent(' ');
/// Saves [json] as [fileName] to device storage (see
/// [DataTransferPlatform.saveText]). False when the user cancelled.
Future<bool> exportJson({
required String fileName,
required Map<String, Object?> json,
}) => exportJsonText(fileName: fileName, text: _pretty.convert(json));
/// [exportJson] for an already-encoded JSON string.
Future<bool> exportJsonText({required String fileName, required String text}) =>
exportTextFile(fileName: fileName, text: text);
/// Saves any text file ([mimeType], e.g. `text/csv`) to device storage, with
/// the same host behaviour as [exportJson]. False when cancelled.
Future<bool> exportTextFile({
required String fileName,
required String text,
String mimeType = 'application/json',
}) => DataTransfer.platform.saveText(
fileName: fileName,
text: text,
mimeType: mimeType,
);
/// Optional second action: offers [json] to the share sheet.
Future<bool> shareJson({
required String fileName,
required Map<String, Object?> json,
}) => DataTransfer.platform.shareText(
fileName: fileName,
text: _pretty.convert(json),
);
/// Lets the user pick a JSON file and decodes it; null when cancelled.
///
/// Throws [EnvelopeInvalidJson] / [EnvelopeNotObject] for a file that is not
/// a JSON object.
Future<Map<String, Object?>?> pickJson() async {
final text = await pickJsonText();
if (text == null) return null;
final Object? decoded;
try {
decoded = jsonDecode(text);
} on FormatException {
throw const EnvelopeInvalidJson();
}
if (decoded is! Map<String, Object?>) throw const EnvelopeNotObject();
return decoded;
}
/// Lets the user pick a file and returns its raw text; null when cancelled.
Future<String?> pickJsonText() => DataTransfer.platform.pickText();

View File

@ -1,28 +0,0 @@
name: data_transfer_flutter
description: >-
Shared "export to a JSON file / import from a JSON file" plumbing for kuhy's
Flutter apps: system Save-as on Android/iOS (never cloud-only), a save
dialog on desktop, a download on web, plus a versioned export envelope.
publish_to: 'none'
version: 0.2.2
environment:
sdk: ^3.12.2
dependencies:
file_picker: 13.3.3
file_selector: 1.1.0
flutter:
sdk: flutter
path: 1.9.1
path_provider: 2.1.6
share_plus: 13.3.1
dev_dependencies:
file_selector_platform_interface: 2.7.0
flutter_test:
sdk: flutter
plugin_platform_interface: 2.1.8
very_good_analysis: ^11.0.0
flutter:

View File

@ -1,32 +0,0 @@
#!/bin/bash
# ============================================================================
# Fail if lcov line coverage drops below a threshold.
#
# Thin delegate to the shared gate in ~/src/utils/scripts, which owns the parsing
# and the threshold comparison. This file used to be a byte-identical copy of
# that logic; three such copies existed, which is how one package's idea of
# "covered" drifts from every other package's.
#
# Usage: check_coverage.sh <lcov.info> <min_percent>
# ============================================================================
set -euo pipefail
# Repo-relative, resolved from this script's own location: an absolute
# /home/... path exits 127 on an Actions runner.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly SHARED_GATE="${SCRIPT_DIR}/../../scripts/check_coverage.sh"
main() {
if [[ ! -f "$SHARED_GATE" ]]; then
echo "Error: shared coverage gate not found at $SHARED_GATE" >&2
echo " It lives in the utils repo at scripts/check_coverage.sh." >&2
exit 1
fi
exec bash "$SHARED_GATE" "$@"
}
main "$@"

View File

@ -1,182 +0,0 @@
import 'dart:convert';
import 'package:data_transfer_flutter/data_transfer_flutter.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
final now = DateTime.utc(2026, 11, 1, 12, 30);
group('buildEnvelope', () {
test('puts format, version and a UTC timestamp first', () {
final envelope = buildEnvelope(
format: 'demo_export',
version: 2,
now: now,
body: {
'items': <Object?>[1, 2],
},
);
expect(envelope.keys, ['format', 'version', 'exported_at', 'items']);
expect(envelope['format'], 'demo_export');
expect(envelope['version'], 2);
expect(envelope['exported_at'], '2026-11-01T12:30:00.000Z');
expect(envelope['items'], [1, 2]);
});
test('converts a local clock to UTC', () {
final local = DateTime.parse('2026-11-01T12:30:00+02:00');
final envelope = buildEnvelope(format: 'f', version: 1, now: local);
expect(envelope['exported_at'], '2026-11-01T10:30:00.000Z');
});
test('refuses a body that shadows an envelope key', () {
expect(
() => buildEnvelope(
format: 'f',
version: 1,
now: now,
body: {'version': 9},
),
throwsArgumentError,
);
});
});
group('validateEnvelope', () {
final good = <String, Object?>{
'format': 'demo_export',
'version': 1,
'exported_at': 'x',
'rest': true,
};
test('returns the same map when format and version match', () {
final out = validateEnvelope(good, format: 'demo_export', version: 1);
expect(out, same(good));
});
test('rejects a non-object as not an object', () {
for (final value in <Object?>[null, 3, 'text', <Object?>[]]) {
expect(
() => validateEnvelope(value, format: 'demo_export', version: 1),
throwsA(isA<EnvelopeNotObject>()),
);
}
});
test('rejects a foreign format and reports both names', () {
expect(
() => validateEnvelope(
{...good, 'format': 'other'},
format: 'demo_export',
version: 1,
),
throwsA(
isA<EnvelopeFormatMismatch>()
.having((e) => e.expected, 'expected', 'demo_export')
.having((e) => e.actual, 'actual', 'other')
.having((e) => e.message, 'message', contains('demo_export')),
),
);
});
test('rejects a missing format', () {
expect(
() =>
validateEnvelope({'version': 1}, format: 'demo_export', version: 1),
throwsA(isA<EnvelopeFormatMismatch>()),
);
});
test('rejects another version and reports both numbers', () {
expect(
() => validateEnvelope(
{...good, 'version': 7},
format: 'demo_export',
version: 1,
),
throwsA(
isA<EnvelopeVersionMismatch>()
.having((e) => e.expected, 'expected', 1)
.having((e) => e.actual, 'actual', 7)
.having((e) => e.message, 'message', contains('7')),
),
);
});
test('rejects a missing version', () {
expect(
() => validateEnvelope(
{'format': 'demo_export'},
format: 'demo_export',
version: 1,
),
throwsA(isA<EnvelopeVersionMismatch>()),
);
});
test('every error is a FormatException', () {
expect(
() => validateEnvelope(null, format: 'f', version: 1),
throwsFormatException,
);
expect(
() => validateEnvelope({'format': 'g'}, format: 'f', version: 1),
throwsFormatException,
);
expect(
() => validateEnvelope(
{'format': 'f', 'version': 2},
format: 'f',
version: 1,
),
throwsFormatException,
);
});
});
group('EnvelopeException', () {
test('behaves as a FormatException with no source position', () {
const FormatException error = EnvelopeInvalidJson();
expect(error.message, 'not valid JSON');
expect(error.source, isNull);
expect(error.offset, isNull);
expect(error.toString(), contains('not valid JSON'));
});
});
group('parseEnvelope', () {
test('round-trips what buildEnvelope produced', () {
final text = jsonEncode(
buildEnvelope(format: 'f', version: 1, now: now, body: {'n': 1}),
);
final parsed = parseEnvelope(text, format: 'f', version: 1);
expect(parsed['n'], 1);
});
test('reports malformed JSON as an invalid-JSON error', () {
expect(
() => parseEnvelope('{oops', format: 'f', version: 1),
throwsA(isA<EnvelopeInvalidJson>()),
);
});
test('runs the format and version checks on valid JSON', () {
expect(
() => parseEnvelope(
'{"format":"g","version":1}',
format: 'f',
version: 1,
),
throwsA(isA<EnvelopeFormatMismatch>()),
);
});
});
}

View File

@ -1,162 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:data_transfer_flutter/src/json_type_group.dart';
import 'package:data_transfer_flutter/src/platform_io.dart';
import 'package:file_selector_platform_interface/file_selector_platform_interface.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
class _FakeSelector extends FileSelectorPlatform
with MockPlatformInterfaceMixin {
FileSaveLocation? saveLocation;
XFile? fileToOpen;
final saveRequests = <String?>[];
@override
Future<FileSaveLocation?> getSaveLocation({
List<XTypeGroup>? acceptedTypeGroups,
SaveDialogOptions options = const SaveDialogOptions(),
}) async {
saveRequests.add(options.suggestedName);
return saveLocation;
}
@override
Future<XFile?> openFile({
List<XTypeGroup>? acceptedTypeGroups,
String? initialDirectory,
String? confirmButtonText,
}) async => fileToOpen;
}
void main() {
late _FakeSelector selector;
late Directory temp;
setUp(() async {
TestWidgetsFlutterBinding.ensureInitialized();
selector = _FakeSelector();
FileSelectorPlatform.instance = selector;
temp = await Directory.systemTemp.createTemp('data_transfer_io');
});
tearDown(() => temp.delete(recursive: true));
group('desktop', () {
final desktop = IoDataTransferPlatform(isMobile: false);
test('the save dialog is typed by the file name and mime type', () {
final group = saveTypeGroup('punchme.csv', 'text/csv');
expect(group.extensions, ['csv']);
expect(group.mimeTypes, ['text/csv']);
});
test('a name without an extension accepts any type', () {
expect(saveTypeGroup('README', 'text/plain').extensions, isNull);
});
test('save writes to the chosen path and suggests the name', () async {
final path = '${temp.path}/out.json';
selector.saveLocation = FileSaveLocation(path);
final saved = await desktop.saveText(fileName: 'x.json', text: '{}');
expect(saved, isTrue);
expect(File(path).readAsStringSync(), '{}');
expect(selector.saveRequests, ['x.json']);
});
test('a cancelled save writes nothing and returns false', () async {
final saved = await desktop.saveText(fileName: 'x.json', text: '{}');
expect(saved, isFalse);
expect(temp.listSync(), isEmpty);
});
test('share falls back to the save dialog', () async {
final path = '${temp.path}/shared.json';
selector.saveLocation = FileSaveLocation(path);
final shared = await desktop.shareText(fileName: 'x.json', text: '[]');
expect(shared, isTrue);
expect(File(path).readAsStringSync(), '[]');
});
test('pick reads the chosen file, null when cancelled', () async {
expect(await desktop.pickText(), isNull);
selector.fileToOpen = XFile.fromData(
Uint8List.fromList('{"a":1}'.codeUnits),
name: 'in.json',
);
expect(await desktop.pickText(), '{"a":1}');
});
});
group('mobile', () {
test(
'save hands UTF-8 bytes to the document saver, not file_selector',
() async {
String? name;
String? mime;
List<int>? captured;
final mobile = IoDataTransferPlatform(
isMobile: true,
documentSaver:
({required fileName, required bytes, required mimeType}) async {
name = fileName;
mime = mimeType;
captured = bytes;
return true;
},
);
final saved = await mobile.saveText(
fileName: 'm.json',
text: 'zażółć',
mimeType: 'text/csv',
);
expect(saved, isTrue);
expect(name, 'm.json');
expect(mime, 'text/csv');
expect(utf8.decode(captured!), 'zażółć');
expect(selector.saveRequests, isEmpty);
},
);
test('a cancelled document picker reports false', () async {
final mobile = IoDataTransferPlatform(
isMobile: true,
documentSaver:
({required fileName, required bytes, required mimeType}) async =>
false,
);
expect(await mobile.saveText(fileName: 'm.json', text: '{}'), isFalse);
});
test('share goes through the share sheet seam', () async {
(String, String)? seen;
String? sharedMime;
final mobile = IoDataTransferPlatform(
isMobile: true,
sheetSharer:
({required fileName, required text, required mimeType}) async {
seen = (fileName, text);
sharedMime = mimeType;
return true;
},
);
expect(await mobile.shareText(fileName: 's.json', text: '1'), isTrue);
expect(seen, ('s.json', '1'));
expect(sharedMime, 'application/json');
});
});
}

View File

@ -1,43 +0,0 @@
import 'package:data_transfer_flutter/data_transfer_flutter.dart';
/// Records what the facade asked the platform to do.
class FakePlatform implements DataTransferPlatform {
/// What every save/share returns; false stands for a cancelled dialog.
bool accept = true;
/// What [pickText] returns; null stands for a cancelled picker.
String? picked;
/// `(fileName, text)` of every [saveText] call.
final saves = <(String, String)>[];
/// The mime type of every [saveText] call, in order.
final saveMimes = <String>[];
/// `(fileName, text)` of every [shareText] call.
final shares = <(String, String)>[];
@override
Future<bool> saveText({
required String fileName,
required String text,
String mimeType = 'application/json',
}) async {
saves.add((fileName, text));
saveMimes.add(mimeType);
return accept;
}
@override
Future<bool> shareText({
required String fileName,
required String text,
String mimeType = 'application/json',
}) async {
shares.add((fileName, text));
return accept;
}
@override
Future<String?> pickText() async => picked;
}

View File

@ -1,113 +0,0 @@
import 'dart:convert';
import 'package:data_transfer_flutter/data_transfer_flutter.dart';
import 'package:flutter_test/flutter_test.dart';
import 'support/fake_platform.dart';
void main() {
late FakePlatform fake;
late DataTransferPlatform original;
setUp(() {
original = DataTransfer.platform;
fake = FakePlatform();
DataTransfer.platform = fake;
});
tearDown(() => DataTransfer.platform = original);
group('exportJson', () {
test('saves the map as indented JSON under the given name', () async {
final saved = await exportJson(fileName: 'a.json', json: {'k': 1});
expect(saved, isTrue);
expect(fake.saves.single.$1, 'a.json');
expect(
fake.saves.single.$2,
const JsonEncoder.withIndent(' ').convert({'k': 1}),
);
expect(fake.shares, isEmpty);
});
test('returns false when the dialog was cancelled', () async {
fake.accept = false;
expect(await exportJson(fileName: 'a.json', json: {}), isFalse);
});
});
group('exportJsonText', () {
test('passes an already-encoded string through untouched', () async {
await exportJsonText(fileName: 'b.json', text: '{"x":1}');
expect(fake.saves.single, ('b.json', '{"x":1}'));
});
});
group('exportTextFile', () {
test('saves non-JSON text with its own mime type', () async {
final saved = await exportTextFile(
fileName: 'a.csv',
text: 'a,b\n',
mimeType: 'text/csv',
);
expect(saved, isTrue);
expect(fake.saves.single, ('a.csv', 'a,b\n'));
expect(fake.saveMimes.single, 'text/csv');
});
test('exportJsonText keeps the JSON mime type', () async {
await exportJsonText(fileName: 'a.json', text: '{}');
expect(fake.saveMimes.single, 'application/json');
});
});
group('shareJson', () {
test('uses the share action, not save', () async {
final shared = await shareJson(fileName: 'c.json', json: {'k': 2});
expect(shared, isTrue);
expect(fake.shares.single.$1, 'c.json');
expect(jsonDecode(fake.shares.single.$2), {'k': 2});
expect(fake.saves, isEmpty);
});
});
group('pickJson', () {
test('decodes the picked file', () async {
fake.picked = '{"a":[1,2]}';
expect(await pickJson(), {
'a': [1, 2],
});
});
test('returns null when the picker was cancelled', () async {
expect(await pickJson(), isNull);
expect(await pickJsonText(), isNull);
});
test('rejects a file that is not JSON', () async {
fake.picked = '{oops';
await expectLater(pickJson, throwsA(isA<EnvelopeInvalidJson>()));
});
test('rejects JSON that is not an object', () async {
fake.picked = '[1]';
await expectLater(pickJson, throwsA(isA<EnvelopeNotObject>()));
});
});
group('pickJsonText', () {
test('returns the raw text without decoding it', () async {
fake.picked = 'not even json';
expect(await pickJsonText(), 'not even json');
});
});
}

View File

@ -64,11 +64,6 @@ MAVEN_REPOS: Final = (
"https://repo1.maven.org/maven2/",
"https://plugins.gradle.org/m2/",
)
# Asked only when none of the above knows an artifact AND the group is not
# `com.github.*` (whose metadata is the 401 above): JitPack also hosts
# artifacts under their own group, e.g. `cz.adaptech.tesseract4android`,
# whose metadata answers 200 with the full release list.
JITPACK_REPO: Final = "https://jitpack.io/"
UTILS_TAG_REMOTE: Final = "https://github.com/kuhyx/utils"
# JitPack coordinates: `com.github.<owner>:<repo>` or
# `com.github.<owner>.<repo>:<module>`; the GitHub repo is what "HEAD" means.
@ -91,8 +86,6 @@ NPM_ACCEPT: Final = "application/vnd.npm.install-v1+json"
# --- Cache ------------------------------------------------------------------
CACHE_PATH_ENV: Final = "DEP_FRESHNESS_CACHE"
# Seconds; overrides every ecosystem TTL (a repo that wants a once-a-day gate).
TTL_ENV: Final = "DEP_FRESHNESS_TTL_SECONDS"
DEFAULT_CACHE_DIR: Final = "~/.cache/dep-freshness"
CACHE_FILE: Final = "registry.json"
TTL_SECONDS: Final = 6 * 3600
@ -215,7 +208,7 @@ ALLOWLIST_MAX_DAYS: Final = 90
TRANSITIVE_PREFIX: Final = "transitive:"
# A fork's predicate: the entry holds only while the upstream repository's own
# copy of the same manifest pins the same version, and dies the day upstream
# bumps. A fork that cannot take a release (e.g. a 300-file accessor rewrite
# that breaks every future upstream string) before its upstream does.
# bumps. TachiyomiSY cannot take moko-resources 0.27.0 (a 300-file accessor
# rewrite that breaks every future upstream string) before jobobby04 does.
UPSTREAM_PREFIX: Final = "upstream:"
GITHUB_RAW: Final = "https://raw.githubusercontent.com/{repo}/HEAD/{path}"

View File

@ -21,7 +21,6 @@ from dep_freshness._tables import (
DEFAULT_CACHE_DIR,
GITCOMMIT,
GITTAG,
TTL_ENV,
TTL_GITTAG_SECONDS,
TTL_SECONDS,
)
@ -34,14 +33,7 @@ def cache_dir() -> Path:
def ttl_for(ecosystem: str) -> int:
"""Git refs move rarely and cost a subprocess, so they cache longer.
`DEP_FRESHNESS_TTL_SECONDS` overrides both; garbage is ignored, because a
typo in an env var must not break a commit hook.
"""
override = os.environ.get(TTL_ENV, "")
if override.isdigit() and int(override) > 0:
return int(override)
"""Git refs move rarely and cost a subprocess, so they cache longer."""
return TTL_GITTAG_SECONDS if ecosystem in (GITTAG, GITCOMMIT) else TTL_SECONDS

View File

@ -3,7 +3,7 @@
The version catalog is the one place a Gradle build should declare versions,
which makes it a manifest the gate can read without evaluating Kotlin DSL.
A build may have several (`settings.gradle.kts` can `from(files(...))` any
`*.versions.toml`; a large app can have several), so the suffix is what is matched.
`*.versions.toml`; TachiyomiSY has three), so the suffix is what is matched.
Three shapes appear in `[libraries]` and `[plugins]`:
foo = "group:artifact:1.2.3" # inline string
@ -12,7 +12,7 @@ Three shapes appear in `[libraries]` and `[plugins]`:
plug = { id = "com.example.plugin", version.ref = "x" }
A library with no version is managed by a BOM (`junit-platform-launcher`
in a typical app) and is skipped: the BOM's own entry is the pin that counts.
in TachiyomiSY) and is skipped: the BOM's own entry is the pin that counts.
A rich version table (`{ strictly = "1.2" }`) is read as the constraint it
spells and lands as unpinned when it is a range, like every other ecosystem.

View File

@ -1,6 +1,6 @@
"""JitPack artifacts pinned to a commit: "newest" is the default branch's HEAD.
Some Android builds depend on `com.github.<owner>:<repo>` at a
Seven of TachiyomiSY's dependencies are `com.github.<owner>:<repo>` at a
short hash. No registry lists a "latest" for those, and a hash is not a
version, so the gate's question becomes the only one a commit pin admits:
is the pinned commit still what the source repository's default branch

View File

@ -16,9 +16,7 @@ artifacts published nowhere else (PhotoView, DirectionalViewPager). Since
themselves still download, so a `com.github.<owner>:<repo>` name that no
repository knows is answered from the GitHub repo's tags over `git
ls-remote` (unmetered, no token). JitPack's version IS the tag name, `v`
prefix included, so the tags are compared as they are. Artifacts JitPack
hosts under their OWN group (`cz.adaptech.tesseract4android`) still get
200 metadata there, so JitPack is the last resort for any other name.
prefix included, so the tags are compared as they are.
`<latest>` and `<release>` in the metadata are not trusted: AndroidX writes
`1.2.0-alpha03` into `<latest>` routinely. The full `<versions>` list goes
@ -34,12 +32,7 @@ from __future__ import annotations
from xml.etree import ElementTree
from dep_freshness._tables import (
GITHUB_REMOTE,
JITPACK_GROUP_PREFIX,
JITPACK_REPO,
MAVEN_REPOS,
)
from dep_freshness._tables import GITHUB_REMOTE, MAVEN_REPOS
from dep_freshness.registries._git import ls_remote
from dep_freshness.registries.gitcommit import source_repo
from dep_freshness.registries.http import get_text
@ -76,15 +69,6 @@ def github_tags(name: str) -> list[str]:
return [line.rpartition("refs/tags/")[2] for line in lines if "refs/tags/" in line]
def _jitpack_versions(name: str) -> list[str]:
"""What only JitPack knows: tags for `com.github.*`, metadata otherwise."""
if name.startswith(JITPACK_GROUP_PREFIX):
return github_tags(name)
url = metadata_url(JITPACK_REPO, name)
body = get_text(url) if url else None
return versions_in(body) if body is not None else []
def latest(name: str) -> str | None:
seen: list[str] = []
for repo in MAVEN_REPOS:
@ -95,5 +79,5 @@ def latest(name: str) -> str | None:
if body is not None:
seen.extend(versions_in(body))
if not seen:
seen.extend(_jitpack_versions(name))
seen.extend(github_tags(name))
return reference(seen) or newest_per_generation(seen)

View File

@ -147,47 +147,6 @@ def test_a_tagless_repo_and_a_non_jitpack_name_are_none(repos, monkeypatch):
assert calls == [("https://github.com/o/r", "--tags", "--refs")]
JITPACK = maven.JITPACK_REPO
TESSERACT = "cz.adaptech.tesseract4android:tesseract4android"
def test_a_jitpack_only_artifact_is_answered_by_jitpack_metadata(repos):
"""Tesseract4Android is published on JitPack under its own group, not
`com.github.*`, so there is no source repo to read tags from; JitPack's
metadata for it answers 200 and lists every release."""
repos[maven.metadata_url(JITPACK, TESSERACT)] = metadata(
"4.7.0", "4.9.0", "4.1.1a", latest="4.9.0"
)
assert maven.latest(TESSERACT) == "4.9.0"
def test_jitpack_is_asked_last_and_only_when_nobody_else_knows_the_name(
repos, monkeypatch
):
asked: list[str] = []
monkeypatch.setattr(
maven, "get_text", lambda url, *a, **k: asked.append(url) or repos.get(url)
)
repos[maven.metadata_url(CENTRAL, "io.coil-kt:coil")] = metadata("2.7.0")
assert maven.latest("io.coil-kt:coil") == "2.7.0"
assert not any(url.startswith(JITPACK) for url in asked)
def test_a_github_jitpack_name_never_asks_jitpack_metadata(repos, monkeypatch):
"""That endpoint answers 401 for `com.github.*`; the tags are the answer."""
asked: list[str] = []
monkeypatch.setattr(
maven, "get_text", lambda url, *a, **k: asked.append(url) or None
)
tags(monkeypatch, {"o/r": ["1.0.0"]})
assert maven.latest("com.github.o:r") == "1.0.0"
assert not any(url.startswith(JITPACK) for url in asked)
def test_a_name_jitpack_does_not_know_either_is_none(repos):
assert maven.latest(TESSERACT) is None
def test_an_unreachable_repo_is_offline(repos, monkeypatch):
tags(monkeypatch, {})
with pytest.raises(Offline):

View File

@ -12,7 +12,6 @@ from pathlib import Path
import pytest
from dep_freshness._tables import TTL_SECONDS
from dep_freshness.cache import Cache, Entry, ttl_for
from dep_freshness.models import Dep
from dep_freshness.registries import http
@ -140,16 +139,3 @@ def _offline(*_args):
def _explode(*_args):
raise RuntimeError("boom")
def test_ttl_env_overrides_the_registry_ttl(monkeypatch) -> None:
"""A repo can stretch the refresh window (once-a-day pre-push gates)."""
monkeypatch.setenv("DEP_FRESHNESS_TTL_SECONDS", "86400")
assert ttl_for("npm") == 86400
assert ttl_for("gittag") == 86400
def test_ttl_env_garbage_falls_back_to_the_default(monkeypatch) -> None:
"""A malformed value must never break a commit hook."""
monkeypatch.setenv("DEP_FRESHNESS_TTL_SECONDS", "soon")
assert ttl_for("npm") == TTL_SECONDS

View File

@ -45,19 +45,12 @@ def test_newest_stable_ignores_unparseable_entries():
# PyPI stub packages date-stamp a fourth component. Capping the regex at
# three reported this exact pin as unpinned *against its own value*.
("==2.33.0.20260712", "2.33.0.20260712"),
# PEP 440 post/dev/pre releases are exact pins too (pytest-asyncio).
("1.4.1.post1", "1.4.1.post1"), ("==1.4.1.post1", "1.4.1.post1"),
("2.0.post3", "2.0.post3"), ("1.0.dev2", "1.0.dev2"),
("==2.0rc1", "2.0rc1"), ("1.2.3a1", "1.2.3a1"), ("1.2b4", "1.2b4"),
("1.0.post1.dev2", "1.0.post1.dev2"),
])
def test_exact_pins_are_recognised(raw, expected):
assert exact_pin(raw) == expected
@pytest.mark.parametrize("raw", ["^1.6.0", ">=1.6.0", "any", "*", "", ">=1,<2", "~1.2",
">=1.4.1.post1", "~=1.4", "1.4.post", "1.4.postx", "1.4.1.post1,<2",
])
@pytest.mark.parametrize("raw", ["^1.6.0", ">=1.6.0", "any", "*", "", ">=1,<2", "~1.2"])
def test_ranges_are_not_exact_pins(raw):
assert exact_pin(raw) is None
@ -68,13 +61,6 @@ def test_behind_compares_versions():
assert not behind("1.8.0", "1.7.0")
def test_a_post_release_is_newer_than_its_base_release():
assert behind("1.4.1", "1.4.1.post1")
assert not behind("1.4.1.post1", "1.4.1")
assert not is_prerelease("1.4.1.post1")
assert newest_stable(["1.4.1", "1.4.1.post1", "1.4.0"]) == "1.4.1.post1"
def test_behind_never_fires_on_unparseable_input():
"""An unpinned constraint is UNPINNED, never dressed up as staleness."""
assert not behind("^1.6.0", "1.7.0")

View File

@ -1,8 +1,8 @@
"""The `upstream:` predicate: does the fork's upstream still pin this version?
A fork cannot always take a release before the project it tracks does --
a library that rewrites every string accessor across 300 files of the fork
would break each future upstream commit that adds a string.
moko-resources 0.27.0 rewrites every string accessor across 300 files of
TachiyomiSY and would break each future upstream commit that adds a string.
"Wait for upstream" is the honest reason, and it is checkable: fetch the
upstream repository's copy of the SAME manifest, parse it with the same
parser, and compare the pin. Equal means the entry holds; different means

View File

@ -160,12 +160,10 @@ def behind(pinned: str, latest: str) -> bool:
# date-stamp a fourth (`types-requests==2.33.0.20260712`), and capping at
# three reported an exact pin as unpinned against itself. A project-name
# prefix (`natural-comparator-1.1`) is part of the pin, since it is what the
# registry's version list spells. PEP 440 `(a|b|rc)N`, `.postN` and `.devN`
# follow the release (`pytest-asyncio==1.4.1.post1`), before the semver suffix.
# registry's version list spells.
EXACT = re.compile(
r"^\s*(?:==\s*)?(?:[A-Za-z][A-Za-z_.-]*?-(?=v?\d))?"
r"v?\d+(?:\.\d+)*(?:(?:a|b|rc)\d+)?(?:\.post\d+)?(?:\.dev\d+)?"
r"(?:[-+][0-9A-Za-z.-]+)?\s*$"
r"v?\d+(?:\.\d+)*(?:[-+][0-9A-Za-z.-]+)?\s*$"
)

View File

@ -14,7 +14,6 @@ library;
export 'src/confirm.dart';
export 'src/empty_state.dart';
export 'src/feedback.dart';
export 'src/fit_grid.dart';
export 'src/section_header.dart';
export 'src/status_colors.dart';
export 'src/theme.dart';

View File

@ -1,199 +0,0 @@
import 'dart:math' as math;
import 'package:design_system/src/tokens.dart';
import 'package:flutter/material.dart';
/// A grid of square cells that always fits its box, so it never scrolls
/// vertically.
///
/// Rows per page come from the height the parent gives it; the overflow moves
/// to further pages in a horizontal [PageView], marked by dots once there is
/// more than one (a "n / N" counter past 12 pages). [itemBuilder] gets the
/// global item index and must fill the cell it is given: a square of up to
/// column width plus [labelHeight], the rows stretching to use up the
/// leftover height.
class FitGrid extends StatefulWidget {
/// Creates the grid.
const FitGrid({
required this.itemCount,
required this.itemBuilder,
this.columns = 3,
this.labelHeight = 0,
this.minSquare = 1,
this.spacing = AppSpacing.sm,
this.padding = const EdgeInsets.symmetric(horizontal: AppSpacing.md),
super.key,
});
/// Number of items across all pages.
final int itemCount;
/// Builds item `index`, counted over all pages.
final IndexedWidgetBuilder itemBuilder;
/// Cells per row.
final int columns;
/// Height reserved under each square, for a caption.
final double labelHeight;
/// Least square side, as a fraction of the column width. Below 1 the
/// builder may be given a cell too short for a full-width square, and must
/// shrink its square to the cell height minus [labelHeight].
final double minSquare;
/// Gap between cells, both ways.
final double spacing;
/// Space around the cells.
final EdgeInsets padding;
@override
State<FitGrid> createState() => _FitGridState();
}
const double _dotSize = AppSpacing.sm;
const double _dotsHeight = AppSpacing.md + AppSpacing.xs;
// Above this many pages a row of dots outgrows a phone width, so the
// indicator becomes a "n / N" counter instead.
const int _maxDots = 12;
class _FitGridState extends State<FitGrid> {
final _controller = PageController();
var _page = 0;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
int _rowsFor(double height, double cellHeight) => math.max(
1,
((height + widget.spacing) / (cellHeight + widget.spacing)).floor(),
);
int _pagesFor(int perPage) =>
math.max(1, (widget.itemCount / perPage).ceil());
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, box) {
final w = widget;
final inner = box.maxWidth - w.padding.horizontal;
final cellW = (inner - w.spacing * (w.columns - 1)) / w.columns;
final height = box.maxHeight - w.padding.vertical;
final minCellH = cellW * w.minSquare + w.labelHeight;
var rows = _rowsFor(height, minCellH);
final pages = _pagesFor(rows * w.columns);
if (pages > 1) {
rows = _rowsFor(height - _dotsHeight, minCellH);
}
final perPage = rows * w.columns;
final count = _pagesFor(perPage);
final pageHeight = height - (count > 1 ? _dotsHeight : 0);
final cellH = math.max(
minCellH,
(pageHeight + w.spacing) / rows - w.spacing,
);
return Column(
children: [
Expanded(
child: PageView.builder(
controller: _controller,
itemCount: count,
onPageChanged: (p) => setState(() => _page = p),
itemBuilder: (context, p) => Padding(
padding: w.padding,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topCenter,
child: _pageOf(
context,
p * perPage,
rows,
Size(cellW, cellH),
),
),
),
),
),
if (count > 1)
_Dots(count: count, current: math.min(_page, count - 1)),
],
);
},
);
Widget _pageOf(BuildContext context, int first, int rows, Size cell) {
final w = widget;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var r = 0; r < rows; r++) ...[
if (r > 0) SizedBox(height: w.spacing),
Row(
children: [
for (var c = 0; c < w.columns; c++) ...[
if (c > 0) SizedBox(width: w.spacing),
SizedBox.fromSize(
size: cell,
child: first + r * w.columns + c < w.itemCount
? w.itemBuilder(context, first + r * w.columns + c)
: null,
),
],
],
),
],
],
);
}
}
class _Dots extends StatelessWidget {
const _Dots({required this.count, required this.current});
final int count;
final int current;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ExcludeSemantics(
child: SizedBox(
height: _dotsHeight,
child: count > _maxDots
? Center(
child: Text(
'${current + 1} / $count',
key: const ValueKey<String>('fit-grid-page-counter'),
style: Theme.of(context).textTheme.labelSmall,
textScaler: TextScaler.noScaling,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < count; i++)
Container(
key: ValueKey<String>('fit-grid-dot-$i'),
width: _dotSize,
height: _dotSize,
margin: const EdgeInsets.symmetric(
horizontal: AppSpacing.xs,
),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == current
? scheme.primary
: scheme.outlineVariant,
),
),
],
),
),
);
}
}

View File

@ -5,7 +5,7 @@ description: >-
helpers that were each reimplemented in three or more repos. Owns the tokens
-- consumers import them rather than transcribing the token table by hand.
publish_to: 'none'
version: 0.3.1
version: 0.2.1
environment:
sdk: ^3.12.2

View File

@ -1,221 +0,0 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
final _dot = find.byWidgetPredicate(
(w) => switch (w.key) {
ValueKey<String>(:final value) => value.startsWith('fit-grid-dot'),
_ => false,
},
);
Future<void> _pump(
WidgetTester tester, {
required int count,
Size size = const Size(300, 400),
int columns = 3,
double labelHeight = 20,
double minSquare = 1,
}) {
return tester.pumpWidget(
MaterialApp(
home: Center(
child: SizedBox.fromSize(
size: size,
child: FitGrid(
itemCount: count,
columns: columns,
labelHeight: labelHeight,
minSquare: minSquare,
itemBuilder: (context, i) =>
Container(key: ValueKey('cell-$i'), child: Text('item $i')),
),
),
),
),
);
}
int _onScreen(WidgetTester tester) => find
.textContaining('item ')
.evaluate()
.where((e) => find.byWidget(e.widget).hitTestable().evaluate().isNotEmpty)
.length;
void main() {
testWidgets('few items: one page, no dots, nothing overflows', (
tester,
) async {
await _pump(tester, count: 4);
expect(_onScreen(tester), 4);
expect(_dot, findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('rows come from the height: taller box fits more per page', (
tester,
) async {
await _pump(tester, count: 60, size: const Size(300, 300));
final short = _onScreen(tester);
await _pump(tester, count: 60, size: const Size(300, 600));
final tall = _onScreen(tester);
expect(short % 3, 0);
expect(tall, greaterThan(short));
});
testWidgets('leftover height goes into the cells: grid fills the box', (
tester,
) async {
await _pump(tester, count: 9);
expect(_onScreen(tester), 9);
final box = tester.getRect(find.byType(FitGrid));
final last = tester.getRect(find.byKey(const ValueKey('cell-8')));
expect(last.bottom, closeTo(box.bottom, 1));
final first = tester.getRect(find.byKey(const ValueKey('cell-0')));
expect(first.height, greaterThan(first.width + 20));
});
testWidgets('minSquare lets a row be shorter than column + label', (
tester,
) async {
await _pump(tester, count: 60, size: const Size(300, 400), labelHeight: 40);
final full = _onScreen(tester);
await _pump(
tester,
count: 60,
size: const Size(300, 400),
labelHeight: 40,
minSquare: 0.6,
);
expect(_onScreen(tester), greaterThan(full));
final cell = tester.getRect(find.byKey(const ValueKey('cell-0')));
final colW = cell.width;
expect(cell.height, greaterThanOrEqualTo(colW * 0.6 + 40 - 0.5));
expect(cell.height, lessThan(colW + 40));
});
testWidgets('paged grid fills the box above the dots', (tester) async {
await _pump(tester, count: 40);
final perPage = _onScreen(tester);
final box = tester.getRect(find.byType(FitGrid));
final last = tester.getRect(find.byKey(ValueKey('cell-${perPage - 1}')));
expect(last.bottom, closeTo(box.bottom - 20, 1));
final dot = tester.getRect(_dot.first);
expect(dot.top - last.bottom, lessThan(12));
});
testWidgets('cells are square plus the label, within the box', (
tester,
) async {
await _pump(tester, count: 3);
final cell = tester.getSize(find.text('item 0').first);
expect(cell.width, greaterThan(0));
final box = tester.getRect(find.byType(FitGrid));
final last = tester.getRect(find.text('item 2'));
expect(box.contains(last.center), isTrue);
});
testWidgets('overflow goes to more pages with one dot per page', (
tester,
) async {
await _pump(tester, count: 30);
final perPage = _onScreen(tester);
expect(perPage, lessThan(30));
final pages = (30 / perPage).ceil();
expect(_dot, findsNWidgets(pages));
expect(find.text('item $perPage').hitTestable(), findsNothing);
});
testWidgets('more than 12 pages: "n / N" counter instead of dots', (
tester,
) async {
await _pump(tester, count: 30);
final perPage = _onScreen(tester);
final count = perPage * 13;
await _pump(tester, count: count);
expect(_dot, findsNothing);
final counter = find.byKey(const ValueKey('fit-grid-page-counter'));
expect(tester.widget<Text>(counter).data, '1 / 13');
await tester.fling(find.byType(PageView), const Offset(-250, 0), 1000);
await tester.pumpAndSettle();
expect(tester.widget<Text>(counter).data, '2 / 13');
expect(tester.takeException(), isNull);
});
testWidgets('exactly 12 pages still shows dots', (tester) async {
await _pump(tester, count: 30);
final perPage = _onScreen(tester);
await _pump(tester, count: perPage * 12);
expect(_dot, findsNWidgets(12));
expect(tester.takeException(), isNull);
});
testWidgets('swiping left shows the next page and moves the dot', (
tester,
) async {
await _pump(tester, count: 30);
final perPage = _onScreen(tester);
Color? active() {
final colors = [
for (final e in _dot.evaluate())
((e.widget as Container).decoration! as BoxDecoration).color,
];
return colors.first;
}
final before = active();
await tester.fling(find.byType(PageView), const Offset(-250, 0), 1000);
await tester.pumpAndSettle();
expect(find.text('item $perPage').hitTestable(), findsOneWidget);
expect(find.text('item 0').hitTestable(), findsNothing);
expect(active(), isNot(before));
});
testWidgets('the last page keeps cell size (partial rows are padded)', (
tester,
) async {
await _pump(tester, count: 30);
final perPage = _onScreen(tester);
final firstWidth = tester.getSize(find.text('item 0')).width;
for (var i = 0; i < 20; i++) {
await tester.fling(find.byType(PageView), const Offset(-250, 0), 1000);
await tester.pumpAndSettle();
}
expect(find.text('item 29').hitTestable(), findsOneWidget);
expect(tester.getSize(find.text('item 29')).width, firstWidth);
expect(perPage, greaterThan(0));
});
testWidgets('a box shorter than one row still shows a row', (tester) async {
await _pump(tester, count: 6, size: const Size(300, 60));
expect(find.text('item 0'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('list shrinking drops the page back inside the range', (
tester,
) async {
await _pump(tester, count: 30);
await tester.fling(find.byType(PageView), const Offset(-250, 0), 1000);
await tester.pumpAndSettle();
await _pump(tester, count: 2);
await tester.pumpAndSettle();
expect(find.text('item 0').hitTestable(), findsOneWidget);
expect(_dot, findsNothing);
});
testWidgets('empty list renders nothing and does not throw', (tester) async {
await _pump(tester, count: 0);
expect(find.textContaining('item '), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('columns is honoured', (tester) async {
await _pump(tester, count: 6, columns: 2);
final x0 = tester.getTopLeft(find.text('item 0')).dx;
final x1 = tester.getTopLeft(find.text('item 1')).dx;
final x2 = tester.getTopLeft(find.text('item 2')).dx;
expect(x1, greaterThan(x0));
expect(x2, x0);
});
}

View File

@ -0,0 +1,165 @@
# Dopamine UX — session prompts
Ten pasteable prompts derived from *"The Dopamine Architecture: How UX Designers
Hold User Attention in 2026"* (london-post.co.uk), applied to kuhy's repos.
Paste **one file's contents** into a fresh Claude session. Each is
self-contained: it restates the findings it depends on, so no session needs the
context of another.
## The two tests — every change must pass both
The article's four mechanics (variable rewards, micro-animations, sound design,
near-miss) are in scope, aimed at **kuhy's own stated goals**. They are gated by
two questions, not by an allow/deny list:
1. **Is the displayed state true?** A progress ring at 93% is legitimate *iff*
you are actually at 93%. A streak is legitimate iff it counts real days.
Dopamine may come from framing, timing and salience — **never from a
fabricated number**.
2. **Is the variance real?** A random pick from a real pool (next workout, next
task) is legitimate. Injected random bonus points to manufacture uncertainty
are not.
### The load-bearing corollary
**Reward the recording, never the number recorded.** In diet-guard the
satisfying moment fires on *logging a meal*, not on hitting a calorie target —
otherwise the app rewards eating less rather than recording honestly, and
corrupts its own data. Same shape in workout_app (reward logging a workout, not
beating a PB) and todo (reward capture, not completion count).
## Running order
`01` gates everything. `07` gates `08`. Everything else is independent.
| # | File | Repo | Findings |
|---|---|---|---|
| 01 | `01-motion-tokens.md` | `~/src/utils` | 3 |
| 02 | `02-structural-check.md` | `~/src/utils` | 2 |
| 03 | `03-diet-guard.md` | `~/src/diet-guard` | 4 |
| 04 | `04-screen-locker.md` | `~/src/screen-locker` | 3 |
| 05 | `05-workout-app.md` | `~/src/screen-locker/stronglift_replacement/workout_app` | 4 |
| 06 | `06-todo.md` | `~/src/todo` | 3 |
| 07 | `07-dufs-theme.md` | `~/src/dufs-cloud` | 2 |
| 08 | `08-dufs-motion.md` | `~/src/dufs-cloud` | 4 |
| 09 | `09-wake-alarm.md` | `~/src/wake-alarm` | 2 |
**27 ranked findings total.**
### Dependency graph
```
01-motion-tokens ──┬─→ 03, 04, 05, 09 (transcribe tokens locally)
├─→ 06 (bump design_system-v0.2.0)
└─→ 07 ─→ 08 (bump web_ui-v0.3.2)
02-structural-check (independent; run after 01 so it has motion tokens to check)
```
## The top three findings fleet-wide
Ranked by value-over-effort from the survey data:
1. **screen-locker streak render** (prompt 04) — `snapshot.streak` is already
populated and no renderer reads it. Zero new state, zero conftest change,
zero new dependency.
2. **diet-guard `_onLogMeal` feedback** (prompt 03) — the flagship case.
Dependency-free via `HapticFeedback` from `flutter/services.dart`.
3. **workout_app finish-workout cue** (prompt 05) — `audioplayers`, `vibration`,
the `assets:` block and the VIBRATE permission are **already present**. Only
the call is missing.
## Distributed copies, and the completion protocol
Each prompt is **also copied into the repo it acts on**, at
`<repo>/prompts/dopamine-ux-*.md`, so you can open a repo and say
"do dopamine-ux-<name>" without going hunting. **This directory stays the source
of truth** — if a prompt needs correcting, fix it here and re-distribute.
| Prompt | Distributed to |
|---|---|
| 01 | `~/src/utils/prompts/dopamine-ux-01-motion-tokens.md` — deleted (done) |
| 02 | `~/src/utils/prompts/dopamine-ux-02-structural-check.md` |
| 03 | `~/src/diet-guard/prompts/dopamine-ux-diet-guard.md` |
| 04 | `~/src/screen-locker/prompts/dopamine-ux-04-screen-locker.md` |
| 05 | `~/src/screen-locker/prompts/dopamine-ux-05-workout-app.md` |
| 06 | `~/src/todo/prompts/dopamine-ux-todo.md` |
| 07 | `~/src/dufs-cloud/prompts/dopamine-ux-07-theme.md` |
| 08 | `~/src/dufs-cloud/prompts/dopamine-ux-08-motion.md` |
| 09 | `~/src/wake-alarm/prompts/dopamine-ux-wake-alarm.md` |
When a session finishes a prompt it **deletes its repo copy** (in the same
commit as the implementation) and **appends a row below**. A finished prompt left
in a repo is indistinguishable from a pending one, and the next session re-runs
it. Partial completion is never recorded as DONE — the file is edited to state
what remains instead.
### Completion log
| Prompt | Status | Impl commit | Note |
|---|---|---|---|
| 01-motion-tokens.md | DONE 2026-08-22 | `f741896` | `motion.md` frozen; `AppDuration`/`AppCurve` + `--duration-*`/`--ease-*` shipped. Tags: `design_system-v0.2.0`, `web_ui-v0.3.2`. |
| 02-structural-check.md | not started | — | — |
| 03-diet-guard.md | not started | — | — |
| 04-screen-locker.md | not started | — | — |
| 05-workout-app.md | not started | — | — |
| 06-todo.md | not started | — | — |
| 07-dufs-theme.md | not started | — | — |
| 08-dufs-motion.md | not started | — | — |
| 09-wake-alarm.md | not started | — | — |
## Keeping these prompts honest
Every path and symbol cited across these files is machine-checked:
```
python3 ~/src/utils/dopamine-ux/verify_prompts.py
```
Exit 0 means every cited path resolves and every cited symbol is still greppable
in the file it is attributed to. **Re-run it after any refactor of the surveyed
repos** — a prompt pointing at a renamed function is worse than no prompt, and a
path check alone cannot catch that (which is why it greps symbols too).
Verified 2026-08-16: 146 paths, 30 symbols, all passing.
## Rules that apply to every prompt
1. **Tag cuts and version bumps are explicit steps.** `todo` consumes
`design_system` at git ref `design_system-v0.1.0`; `dufs-cloud/web` consumes
`@kuhyx/web-ui` at `web_ui-v0.3.1`. Adding tokens to those packages does
nothing for consumers until a new tag is cut. Prompt 01 ends by cutting
`design_system-v0.2.0` and `web_ui-v0.3.2`; prompts 06 and 08 begin by
bumping to those literal refs.
2. **Anchor on symbols, not line numbers.** Line numbers in these prompts are
"as of 2026-08-16" and will drift. The symbol name is the real anchor —
grep for it rather than trusting the line.
3. **Reduce-motion is mandatory and is NOT the sound toggle.** Web:
`@media (prefers-reduced-motion: reduce)`. Flutter:
`MediaQuery.of(context).disableAnimations`. The OS motion preference is
honoured automatically; the sound opt-out is a separate user-facing toggle.
4. **Motion and haptics first, sound second.** Haptics need no dependency;
sound needs a pubspec dep, an `assets:` block, and a settings toggle. Each
prompt stages them separately so a short session still lands the valuable
half, and sound stays independently revertable.
5. **Sound ships on (opt-out), never in screen-locker enforcement paths.**
6. **250-line cap** applies to code and prose alike (`~/src/utils/file_length`).
## Source
Article findings, verbatim from the piece:
- **Variable reward schedules** — unpredictable outcomes produce stronger
dopaminergic activity than guaranteed ones. Duolingo streaks, feed ordering,
pull-to-refresh.
- **Micro-animations** — "a button that bounces on press, a like counter that
ticks upward with a slight delay". Replaces latency with feedback, so waiting
reads as responsiveness rather than frustration.
- **Sound design** — "operates below the threshold of conscious attention";
the article's judgement is that it is more powerful than visual design and
gets less critical attention.
- **Near-miss effects** — a fitness ring at ninety-three per cent creates
discomfort that drives re-engagement, and works *even when the user is
explicitly told the mechanism*.
- Core claim: **dopamine is released in anticipation of a reward, not on
receipt. The uncertainty is the trigger.**

View File

@ -0,0 +1,140 @@
Add motion, haptic and sound tokens to the unified design system
## findings (3, ranked by value over effort)
1. **No motion token exists in any stack** — greenfield. Every app that wants an
animation currently invents its own number, and one already has.
2. **No haptic vocabulary exists** — the highest-value gap, because
`HapticFeedback` needs no dependency, so three of four apps can adopt it
the day this lands.
3. **`tokens.md` is 260 lines against a 250 cap** — it cannot absorb a new
section, so motion needs a sibling file.
## what
The shared design system has colour, spacing, radius, type and shadow tokens —
and **no motion tokens at all**. Verified 2026-08-16: no `Duration`, `Curve`,
`--duration-*`, `--ease-*`, or haptic constant exists in any stack copy. There is
not even a prose position that motion is out of scope.
Consequence across kuhy's Flutter fleet: **exactly one animation exists in four
apps** — a lone `AnimatedContainer(duration: Duration(milliseconds: 200))` with an
inline magic number. Every other transition is a Flutter/browser default.
Create the motion token layer so the per-app prompts (03–09) have something
canonical to consume instead of each inventing durations.
## where
Repo: `~/src/utils` (git repo, has CI + the file-length gate wired).
**Create** `~/src/utils/unified-design-system/motion.md` — the prose freeze.
**Do NOT append to `~/src/utils/unified-design-system/tokens.md`.** It is 260 lines
and the cap is 250, so it already fails the gate; adding to it makes that worse.
`screen-size.md` was split out of it for exactly this reason — follow that
precedent and add a pointer line in `tokens.md` only if you can do so without
growing it (i.e. by shortening another line).
Then mirror the tokens into the three code stacks:
| Stack | File | Form |
|---|---|---|
| Flutter/Dart | `~/src/utils/design_system/lib/src/tokens.dart` | `class AppDuration` / `class AppCurve` |
| web/React | `~/src/utils/web_ui/src/tokens.css` | `--duration-*`, `--ease-*` custom props |
| Python/Tkinter | `~/src/utils/gatelock/gatelock/_window.py` (`class LockConfig`) | int ms fields — **only if meaningful**; see `must not` |
There are **three** code stacks plus the prose freeze. Do not go hunting for a
fourth — it does not exist.
## must
- Define a **small** scale. Recommended, but use judgement:
`instant 0ms · fast 120ms · base 200ms · slow 320ms`, and easing
`standard (0.2, 0, 0, 1)` · `decelerate (0, 0, 0, 1)` · `accelerate (0.3, 0, 1, 1)`.
Justify each value in `motion.md` in one line — the file is a freeze, so the
reasoning has to survive without you.
- Include a **haptic** vocabulary mapped to Flutter's `HapticFeedback` API:
which semantic event gets `selectionClick` vs `lightImpact` vs `mediumImpact`.
This is the highest-value part — `HapticFeedback` needs no dependency, so
three of four apps can adopt it immediately.
- Include a **sound** vocabulary: named semantic cues (`confirm`, `complete`,
`error`) with the rule that sound ships **on** (opt-out) per app, and that
each app owns its own asset files.
- State the reduced-motion contract in `motion.md`: web uses
`@media (prefers-reduced-motion: reduce)`, Flutter uses
`MediaQuery.of(context).disableAnimations`. Motion tokens collapse to `0ms`
under it; **haptics and sound are unaffected** (they are not motion).
- Keep every file at or under **250 lines** (`~/src/utils/file_length/config.py`,
`MAX_LINES = 250`). `.md` and `.py` are capped; `.css` is not.
- **Cut tags at the end** — this is the step that makes the work reach consumers:
- `design_system-v0.2.0` (consumed by `~/src/todo`, currently on `v0.1.0`)
- `web_ui-v0.3.2` (consumed by `~/src/dufs-cloud/web`, currently on `v0.3.1`)
Print both tag names at the end of the session; prompts 06, 07 and 08 need them.
- must not: add motion values to the Tkinter `LockConfig` just for symmetry.
Tkinter has no animation framework. If a duration has no meaning there, mark
the row `n/a (tk)` in `motion.md` and leave the Python alone. An honest `n/a`
beats a dead constant.
- must not: express any motion token as a hex-looking value. The existing
`palette_check.py` parses `#RRGGBB` and would either ignore it or trip on it.
- must not: touch any colour token, or add a colour alongside the motion work.
That *would* break `palette_check.py`'s completeness half.
- must not: add `# noqa` or `type: ignore` anywhere.
- optional: a `prefers-reduced-motion` helper in
`~/src/utils/design_system/lib/src/` and/or `web_ui/src/` so consumers get the
collapse behaviour without reimplementing it. Nice, not required.
## done
All four hold:
1. `~/src/utils/unified-design-system/motion.md` exists, is ≤250 lines, and defines
duration, easing, haptic and sound vocabularies with a one-line rationale each.
2. `python3 ~/src/utils/file_length/check.py` (or
`~/src/utils/scripts/check_file_length.sh --all`) reports **no new** violations.
`tokens.md` (260), `nielsen-audit.md` (258) and `README.md` (257) are
pre-existing failures — do not fix them here, but do not add a fourth.
3. `python3 ~/src/utils/unified-design-system/scripts/palette_check.py` still exits 0.
4. Both tags exist: `git -C ~/src/utils tag | grep -E 'design_system-v0.2.0|web_ui-v0.3.2'`
prints two lines.
## verify
Desktop, any way you like — this prompt ships no UI. Run the two gate commands in
`done` and paste their output. Then confirm the token names you chose are what
prompts 03–09 will reference by printing the Dart class and the CSS custom-prop
block.
## read first
- `~/src/utils/unified-design-system/tokens.md` — the existing freeze; match its
table format and tone exactly. Note its length problem before editing.
- `~/src/utils/unified-design-system/screen-size.md` — the precedent for a sibling
file split out of `tokens.md` (32 lines; short and self-contained).
- `~/src/utils/design_system/lib/src/tokens.dart` — `AppPalette`, `AppSpacing`,
`AppRadius`, `AppTextSize`. Your `AppDuration`/`AppCurve` sit alongside these.
- `~/src/utils/web_ui/src/tokens.css` — the full custom-prop list.
- `~/src/utils/design_system/lib/src/feedback.dart` — `showToast`/`showError` and
`_show`. `confirm.dart` next to it **already imports
`package:flutter/services.dart`**, so `HapticFeedback` is one call away with no
new import. This is the natural home for a shared haptic helper.
- `~/src/utils/unified-design-system/scripts/palette_check.py` — read the four
`parse_*` regexes to see why motion tokens are invisible to it. Prompt 02
builds the checker that covers them; **do not extend this script here.**
## context you would otherwise rediscover
- **Green CI does not mean no drift for these tokens.** `palette_check.py` only
matches `#RRGGBB`, so a `--duration-fast: 120ms` is invisible to every one of
its parsers. `palette_map.py:159` claims structural values are "checked by the
scale check" — **no such script exists**. Prompt 02 writes it.
- `~/src/utils/gatelock/` is a package inside the `~/src/utils` monorepo, not a
standalone repo. It is consumed as a pinned git dependency.
- Only `~/src/todo` depends on `design_system`; diet-guard, workout_app and
wake_alarm hand-transcribe the token values into local `lib/ui/theme.dart`
copies. So tokens added here reach exactly one app automatically — the others
are handled per-prompt.
REMOVE ME AFTER FINISH

View File

@ -0,0 +1,139 @@
Add a structural-token drift checker to CI
## findings (2, ranked by value over effort)
1. **Spacing, radius and type have never been drift-checked** — `palette_check.py`
parses hex only, so three whole token categories can diverge across stacks
while CI stays green. Motion inherits the same hole.
2. **`palette_map.py` points at a "scale check" that does not exist** — a
comment describing a guarantee nobody wrote.
## what
`palette_check.py` guarantees the colour palette agrees across all stack copies.
It does **not** check anything else — verified 2026-08-16 by reading its four
parsers, every one of which requires a `#RRGGBB` value:
```python
parse_css : r"(--[a-z0-9-]+)\s*:\s*(#[0-9A-Fa-f]{6})\s*;"
parse_dart: r"static const Color (\w+) = Color\(0x[fF]{2}([0-9A-Fa-f]{6})\)"
parse_tk : r'^\s{4}(\w+):\s*str\s*=\s*"(#[0-9A-Fa-f]{6})"'
parse_md : r"^\|\s*`([a-z0-9-]+)`\s*\|\s*`(#[0-9A-Fa-f]{6})`"
```
So **spacing, radius and type have been silently unprotected all along**, and the
motion tokens added by prompt 01 inherit the same hole. A `--duration-fast: 120ms`
is invisible to every parser: CI passes while the stacks drift apart.
Worse, `scripts/palette_map.py:159` claims structural values are "checked by the
scale check" — **no such script exists anywhere in `~/src/utils`**. That comment
describes a check that was never written. This prompt writes it.
## where
Repo: `~/src/utils`.
**Create** `~/src/utils/unified-design-system/scripts/structural_check.py`.
**Edit** `~/src/utils/.github/workflows/palette-drift.yml` — add a step invoking it.
The workflow deliberately has no `paths:` filter; keep it that way.
**Fix** the stale comment at `~/src/utils/unified-design-system/scripts/palette_map.py`
(~:159) to name the real script.
Sources to compare (three code stacks + the prose freeze):
| Role | Path |
|---|---|
| prose freeze | `~/src/utils/unified-design-system/tokens.md` (spacing/radius/type) |
| motion freeze | `~/src/utils/unified-design-system/motion.md` (**created by prompt 01** — run it first) |
| web/React | `~/src/utils/web_ui/src/tokens.css` |
| Flutter/Dart | `~/src/utils/design_system/lib/src/tokens.dart` |
| Python/Tkinter | `~/src/utils/gatelock/gatelock/_window.py` (`class LockConfig`) |
## must
- Cover **motion** (durations, easing) **and** the already-unprotected
**spacing, radius and type** scales, in one checker.
- Mirror `palette_check.py`'s two-halves design, because it is the part that
actually works:
1. **Agreement** — for each mapped token, resolve it in every non-`n/a` stack
and fail if values disagree.
2. **Completeness, bidirectional** — every structural value parsed out of any
source must be mapped, or explicitly exempted. This is what catches
"added it to one stack, forgot the others".
- Support an explicit **`n/a` per stack**, and require a reason string. Tkinter
has no animation framework; a duration legitimately does not exist there. An
honest `n/a` with a reason is correct — a fabricated constant is not.
- **Adjudicate with exit codes.** Exit non-zero on drift with a message naming
the token and the disagreeing files. No warnings-only mode.
- Keep every file ≤ **250 lines** (`~/src/utils/file_length/config.py`). Note
`palette_check.py` is 231 and `palette_map.py` is 162 — that pair was split
precisely to stay under the cap. **Split yours the same way from the start**
(`structural_check.py` + `structural_map.py`) rather than discovering the
problem at 251 lines.
- Ruff-clean under the repo's config. No `# noqa`, no `type: ignore`.
- **Expect it to fail on first run.** Spacing/radius/type have never been
compared; there may be real pre-existing drift. That is the point of writing
it. Fix whatever it surfaces, in the stack copy that is wrong, and say in the
session summary what drifted.
- must not: weaken the checker, add an exemption, or narrow its scope to make it
green. If a token genuinely differs per stack for a good reason, encode it as
an `n/a`/variant **with the reason recorded**, not as a silent skip.
- must not: modify `palette_check.py`. It works; it is simply colour-only. The
two scripts run side by side.
- must not: change any token *value* to resolve a disagreement without saying so
explicitly — that is a design change, and `tokens.md` is a freeze. Surface it
and let kuhy decide which value is canonical.
- optional: teach it the shadow tier too, if that falls out cheaply.
## done
1. `python3 ~/src/utils/unified-design-system/scripts/structural_check.py` exits 0,
and exits non-zero when you deliberately break one stack copy (**test both
directions** — a checker that never fails is not a checker).
2. It is wired into `~/src/utils/.github/workflows/palette-drift.yml` and the
workflow passes.
3. `palette_map.py`'s "scale check" comment names the real script.
4. `~/src/utils/scripts/check_file_length.sh --all` reports no new violations.
5. The session summary states what drift, if any, the first run found.
## verify
Desktop. Run the script, paste the output. Then prove it fails: change one
spacing or duration value in **one** stack copy, re-run, paste the non-zero exit
and the message, and revert. Both halves of that demonstration are required —
the passing run alone proves nothing.
## read first
- `~/src/utils/unified-design-system/scripts/palette_check.py` — the design to
mirror. Read `NON_COLOUR_CSS`: it is the regex that currently *excludes*
spacing/radius/type/shadow from the colour check, and therefore the precise
list of what you are now covering.
- `~/src/utils/unified-design-system/scripts/palette_map.py` — the `PALETTE` tuple of
`Token(canonical, md, css, dart, tk, why)` rows. Your map wants the same shape,
including the `why` field.
- `~/src/utils/.github/workflows/palette-drift.yml` — how `palette_check.py` and
`ramp_check.py` are invoked; add your step alongside.
- `~/src/utils/unified-design-system/motion.md` — what prompt 01 created.
- `~/src/utils/unified-design-system/tokens.md` — the spacing/radius/type tables.
**Do not edit it**; it is 260 lines and already over the cap.
## context you would otherwise rediscover
- The four "stack copies" are really **three code stacks plus a prose freeze**.
There is no fourth code stack; do not hunt for one.
- `~/src/dufs-cloud/app/lib/ui/theme.dart` is a **fifth, uncompared copy** of the
palette (the Flutter app has no `design_system` dependency and hand-restates
every hex). It is out of scope here — prompt 07 wires it up properly. Do not
add it as a source to this checker; wait until 07 has removed it.
- Three Flutter apps (diet-guard, workout_app, wake_alarm) similarly
hand-transcribe tokens into local `lib/ui/theme.dart` files. Those are
deliberately outside the checker's scope for now — bringing them in is a much
larger job than this prompt.
- `~/src/utils/gatelock/` is a package inside this monorepo, not a separate repo.
REMOVE ME AFTER FINISH

View File

@ -0,0 +1,170 @@
Make logging a meal feel satisfying in diet_guard_app
## findings (4, ranked by value over effort)
1. **`_onLogMeal()` gives zero feedback** — no haptic, no sound, no animation, no
confirmation. The flagship gap. Fix needs no new dependency.
2. **Streak text is inert** — "Adherence streak: N days" is a plain `Text` that
blinks to a new value. Real data, no salience.
3. **No VIBRATE permission** — blocks haptics until added to the manifest.
4. **No audio capability at all** — no package, no `assets:` block. Step 2 only.
## what
Logging a meal currently produces **no feedback whatsoever**. `_onLogMeal()` in
`lib/screens/log_meal_screen.dart` writes the meal, syncs it, clears the fields,
and calls `setState(() { _progress = buildTodayProgress(log); })`. The progress
card silently appears. No SnackBar, no haptic, no sound, no animation.
That is the whole problem. In kuhy's words: *"noting what you ate should be
SATISFYING and ADDICTING so you WANT TO ACTUALLY note down what you ate."* The
data quality of the entire app depends on the logging habit surviving the days
you don't feel like it, and right now the act of logging gives nothing back.
Make the moment of logging land: an immediate haptic, a real confirmation, and
the streak/progress reacting visibly rather than blinking into existence.
## where
Repo: `~/src/diet-guard`. Flutter app: `~/src/diet-guard/app` (package `diet_guard_app`).
Primary:
- `app/lib/screens/log_meal_screen.dart` — `_onLogMeal()` (line 122-168 as of
2026-08-16; **grep the symbol, the line will drift**). The `setState` that
silently swaps in the progress card is at ~:164-166. Card rendered at ~:240.
- `app/lib/widgets/today_progress_card.dart` — `TodayProgress` model (~:15),
`TodayProgressCard` (~:64). Consumed/budget kcal ~:100, P/C/F macro row ~:122,
**"Adherence streak: N days"** ~:129-130 — all plain `Text`, no ring, no bar.
- `app/lib/widgets/streak_summary_row.dart` — `StreakSummaryRow`: logging streak,
adherence streak, YTD tally. Also text-only.
- `app/lib/screens/log_meal_progress.dart` — `buildTodayProgress(DayLog)` (~:18),
`adherenceStreak(...)` (~:31).
Theme (local hand-copy, **no `design_system` dependency**):
- `app/lib/ui/theme.dart` — `buildAppTheme()` ~:12, `AppStatusColors`
ThemeExtension ~:89, redeclared `AppSpacing` ~:124, `AppRadius` ~:146,
`AppTextSize`, plus an `AppWidth` class the shared package does not have.
Settings (for the sound opt-out in step 2):
- `app/lib/screens/settings_screen.dart` — body is a `ListView` inside
`Center > ConstrainedBox(maxWidth: AppWidth.prose)` at ~:161-165.
**No `SwitchListTile` exists anywhere in this app** — you are adding the first.
- `app/lib/services/app_settings_service.dart` — the store. **Not
SharedPreferences**: a singleton over a `DocumentStore` writing JSON to
`app_settings.json` (~:26). `init()` ~:49, `_load()` ~:95-112, setters funnel
through `_persist`/`_writeToDisk` ~:147-165 (read the comment at ~:153-155 —
`_writeToDisk` is the single write path every setter must use).
`resetForTesting`/`initForTesting` at ~:66/:74.
## must
**Step 1 — motion + haptics (no new dependency; land this first).**
- On successful meal log, fire `HapticFeedback.mediumImpact()` (or the token
vocabulary's "complete" cue if prompt 01 defined one). `HapticFeedback` comes
from `package:flutter/services.dart` — **no pubspec change needed**. Add
`<uses-permission android:name="android.permission.VIBRATE"/>` to
`app/android/app/src/main/AndroidManifest.xml`; it is currently absent.
- Fire it **immediately on the tap**, before the write/sync completes. The
article's point about micro-animations is that they replace latency with
feedback; a haptic that waits for a network round-trip defeats it.
- Animate the progress card's arrival and the numbers' change rather than
snapping: the kcal figure and the streak count should visibly move to their new
value. Use the motion tokens from prompt 01.
- Show an explicit confirmation of what was recorded — the user should not have
to infer success from a card appearing.
- Honour `MediaQuery.of(context).disableAnimations`: collapse durations to zero.
Haptics still fire (they are not motion).
**Step 2 — sound (separate, independently revertable).**
- Add an audio dependency and an `assets:` block to `app/pubspec.yaml` (neither
exists today), plus a short confirmation cue asset.
- Sound ships **on**, with an opt-out `SwitchListTile` in `settings_screen.dart`,
backed by a new bool in `app_settings_service.dart` following the existing
field pattern: private field + static getter, parsed in `_load()`, written via
`_writeToDisk`.
- The toggle controls **UI sound only**. Never route notification-channel
behaviour through it.
**Both steps:**
- must not: **reward the calorie number.** The satisfying moment fires on
*logging*, not on being under budget. Rewarding a low number teaches the user
to eat less rather than record honestly, and corrupts the app's own data. A
meal that blows the budget gets the same confirmation as one that doesn't.
- must not: fabricate or round up any displayed value. The streak counts real
consecutive days; the progress bar reflects the true ratio. Framing, timing
and salience are fair game — the numbers are not.
- must not: add a celebratory cue to *deleting* or editing a meal. Only the
record-creating act gets it.
- must not: touch `~/src/diet-guard/diet_guard/` (the Python desktop side) — it has
its own streak logic and is out of scope here.
- optional: a progress ring instead of the plain kcal text. The article's
near-miss point applies honestly here — a ring at 80% of budget is truthful
and more motivating than a number. Only if it stays accurate.
## done
1. Logging a meal on the phone produces a haptic within ~50ms of the tap, an
explicit confirmation, and a visible (not instant) update of the streak/progress.
2. `cd ~/src/diet-guard/app && flutter analyze` is clean.
3. `cd ~/src/diet-guard/app && flutter test` passes.
4. With OS "remove animations" enabled, the screen still works and durations are
zero — verified by toggling it, not by reading the code.
5. Step 2 only: the settings toggle flips, persists across an app restart, and
silences the cue.
## verify
**On the phone — this app is mobile-primary; desktop is not sufficient.**
```
adb devices # confirm 23181JEGR08034 is attached
cd ~/src/diet-guard/app
flutter build apk --release
adb install -r build/app/outputs/flutter-apk/app-release.apk
```
Never `flutter install`, never `adb uninstall`, never `pm clear` — those wipe
real logged food data. `adb install -r` preserves it.
Then log a real meal on the device and report what you felt and saw. A haptic is
a physical outcome: state what the phone did, do not claim it worked from code.
If `com.kuhy.*` focus-mode kills the app on launch, the app needs a focus
whitelist entry before it can be tested.
## read first
- `app/lib/screens/log_meal_screen.dart` — `_onLogMeal()`. Read the whole method
before editing; the sync call and the field-clearing both matter for where the
haptic goes.
- `app/lib/services/app_settings_service.dart` — the `dailyKcalGoal` field
(~:34/:40) is the pattern to copy for a new bool. Note the `_writeToDisk`
comment.
- `~/src/diet-guard/diet_guard/_calendar_view.py` — `streaks_text()` ~:136 and
`ytd_text()` ~:154. The Dart `streak_summary_row.dart` docstring says it mirrors
this formatting; keep them consistent if you change wording.
- `~/src/utils/unified-design-system/motion.md` — the motion/haptic vocabulary from
prompt 01. **Prompt 01 must have run first.**
## context you would otherwise rediscover
- This app has **no `design_system` dependency** — `app/lib/ui/theme.dart` is a
hand-transcription of the shared tokens (values are byte-identical, names in
comments). Motion tokens from prompt 01 must be **transcribed into that local
theme file**, matching the existing convention. Do not add the dependency here;
that is a separate, larger refactor.
- Capability baseline, verified 2026-08-16: **no audio package, no `assets:`
block, no `vibration` package, no VIBRATE permission.** The only existing
audio-adjacent thing is the `diet_guard_due_slot` notification channel
(`app/lib/services/notification_backend_io.dart` ~:17-18, :52-55) at
`Importance.high` with no explicit sound/vibration override — leave it alone.
- The app already computes both a **logging streak** and an **adherence streak**
(`_daystatus.py:144`/`:153` on the Python side, mirrored in Dart). You are
making existing true data feel like something, not inventing a new metric.
- diet-guard state lives under XDG (`~/.local/share/diet_guard`), unlike
screen-locker's in-repo JSON. Tests isolate it via
`~/src/diet-guard/diet_guard/tests/conftest.py:97` `_isolate_state`.
REMOVE ME AFTER FINISH

View File

@ -0,0 +1,173 @@
Surface the workout streak that screen-locker already tracks
## findings (3, ranked by value over effort)
1. **`snapshot.streak` is populated and no renderer reads it** — the cheapest
win in the whole project. Zero new state, zero conftest change, zero deps.
2. **Week-transition reward strings are discarded into `_logger.info`** —
fully-formed celebratory sentences that never reach a user.
3. **The success moment exists only on the lock screen**, for 1.5s. The status
window — the surface visited *voluntarily* — shows only enforcement.
## what
screen-locker computes a real workout streak and celebratory reward text, then
throws almost all of it away. Verified 2026-08-16:
- `StatusSnapshot` (`screen_locker/_status_types.py` ~:88-101) carries populated
`streak` and `early_bird_extended` fields, filled by
`_status_data.py` ~:210-214. **Grep confirms no renderer reads them.**
- `_extra_benefits.py` `process_week_transition()` (~:82, reward strings built at
~:130-139) produces fully-formed celebratory sentences — and
`_startup_checks.py` ~:57-58 sends them to `_logger.info` and nowhere else.
- The one success moment that exists is on the **lock screen**:
`_unlock_view.py` `unlock_screen()` (~:25) paints "Great job! 💪" and
`🔥 {streak}-week streak`, for `_UNLOCK_DELAY_MS = 1500` before routing away.
So the surface the user visits **voluntarily** — the status window, opened from
the tray icon — shows enforcement and budget depletion, and no evidence of
success at all. This is the cheapest high-value change in the whole project:
the data already exists, is already true, and needs no new state.
## where
Repo: `~/src/screen-locker`.
Primary:
- `screen_locker/_status_sections.py` — the section renderers used by the status
window (`_section_*` functions). **177 lines; has headroom.** Add
`_section_streak` here.
- `screen_locker/status_view.py` — `StatusWindow`, `render()`. Call the new
section from here. ⚠️ **246 lines — only 4 left under the 250 cap.** Expect to
extract something, or add the call in a way that costs ~1 line.
- `screen_locker/_status_types.py` — `StatusSnapshot` (~:88-101), the fields you
are rendering. Read-only for this task.
- `screen_locker/_extra_benefits.py` (179 lines) — `process_week_transition()`
and its reward strings; `current_streak()`, `has_extended_early_bird()`.
- `screen_locker/_startup_checks.py` ~:57-58 — where the reward strings are
currently discarded into a log call.
- `screen_locker/_unlock_view.py` (72 lines, lots of headroom) — the existing
success moment, if you extend it.
## must
- **Render the already-populated fields.** Add `_section_streak` to
`_status_sections.py` showing the weekly streak and the extended-early-bird
state, and call it from `status_view.py`'s `render()`.
Zero new state, zero conftest change, zero new dependency.
- **Stop discarding the reward strings.** `process_week_transition()` returns
human-readable celebratory sentences that currently only reach the log. Surface
them where the user will actually see them — the status window is the obvious
home; a milestone is worth more than a silent counter increment.
- Match the existing positive-status styling rather than inventing one:
`_status_sections.py` ~:73-78 already renders `"{n} above the weekly minimum!"`
in success green, and uses `✓` marks at ~:36 and ~:57.
- Keep the streak **true**. It counts real ISO weeks with ≥5 counted workouts
(`_weekly_check.py`: `WEEKLY_WORKOUT_MINIMUM = 5`, `COUNTED_WORKOUT_TYPES =
{phone_verified, runnerup_verified, manual_workout}`). Do not round, pad, or
"encourage" the number.
- must not: **make any reward reduce enforcement.** This is recorded in the
repo as a deliberate decision:
> `_startup_checks.py` — "there is no banked 'skip a workout' credit —
> that mechanic works against the goal of maximizing weekly workouts, so it was
> removed."
Every reward here is **display only**. Do not reintroduce a
banked skip, a streak freeze, a grace day, or any mechanic that lowers the
floor. A dopamine loop that lets you skip the workout defeats the app.
- must not: **grant shutdown time for a streak, a week or a milestone.** The
weekly banked shutdown bonus (and the 4-week `_MILESTONE_INTERVAL` +1h) was
removed on purpose (2026-10-10): every shutdown minute comes from the
`earned_time` ladder and stops at its daily ceiling. Rewards here are display
only — no "+Nh" weekly reward, no bonus-hours field, no new shutdown source.
- must not: add **sound** to any enforcement or lock path. screen-locker is a
self-restriction tool; a chime on lock is punishment audio, and a chime on
unlock trains the wrong association. This repo stays silent. (Sound is
opt-out-on elsewhere in the fleet; here it is simply absent.)
- must not: add a write tool to the MCP server. `_mcp.py` ~:14-26 states it is
read-only by design. Adding `streak` to the existing `get_status` output is
fine; a new action tool violates a stated invariant.
- must not: add `# noqa` (banned repo-wide) or a silent `except`
(`scripts/check_silent_failures.py` — every handler re-raises or logs at
warning or above).
- must not: create a new on-disk state file unless genuinely unavoidable. If you
do, you **must** add it to `_ISOLATED_STATE` in
`screen_locker/tests/conftest.py` (~:134), listing *every* module that binds
the path at import time — a missed binding lets tests write to kuhy's real
state. Read the comment at ~:131-133 and the fixture docstring at ~:170-175.
- optional: a daily streak alongside the weekly one. Note this **would** be new
state (the existing streak is consecutive-*weeks*), so it triggers the conftest
requirement above. diet-guard's consecutive-*day* streak
(`~/src/diet-guard/diet_guard/_daystatus.py` ~:144) is the precedent to mirror.
## done
1. Opening the status window (tray icon → click, or `screen-locker-status`) shows
the current streak and early-bird state — with the real values
from `extra_benefits_state.json`, not placeholders.
2. A week-transition milestone reaches the UI instead of only `_logger.info`.
3. `cd ~/src/screen-locker && python -m pytest` passes.
4. `pre-commit run --files <changed files>` is clean — including the 250-line cap
(`scripts/check_file_length.py`) and the silent-failure check.
5. `git diff` touches no enforcement logic: no change to lock decisions, weekly
minimums, or shutdown floors.
## verify
Desktop — this is a desktop Python app.
```
cd ~/src/screen-locker
python -m screen_locker --status # CLI: confirm the values it prints
screen-locker-status # the Tk window: confirm they render
```
Paste both outputs. The CLI already prints the streak and early-bird state
(`_status.py`), so it is your ground truth: the window must show the **same numbers**.
If they disagree, the renderer is wrong, not the data.
Current real state for comparison: `screen_locker/extra_benefits_state.json` and
`screen_locker/workout_log.json` (12 days of entries, 2026-07-12 → 2026-08-15).
## read first
- `screen_locker/_status_sections.py` — every existing `_section_*`. Match their
signature and style; note the success-green precedent at ~:73-78.
- `screen_locker/_status_types.py` ~:88-101 — the three fields you are rendering.
- `screen_locker/_extra_benefits.py` — especially the module docstring ~:5-8
(the enforcement invariant) and the reward strings ~:130-139.
- `screen_locker/_unlock_view.py` — the existing celebration, for tone.
- `screen_locker/status_view.py` — check its line count **before** editing.
- `~/src/screen-locker/CLAUDE.md` — repo rules (no silent failures, ruff `select=ALL`).
## context you would otherwise rediscover
- **`workout_log.json` is the single source of truth**, written only through
`_log_mixin.py` `write_signed_entry()` (~:82), HMAC-signed via
`gatelock.log_integrity`, deduped on `workout_id`. The Flutter app converges on
it via `_manual_push.py`. There is no second history — a streak computed from
it is authoritative.
- All persistence is **JSON files, no sqlite**; paths declared in
`screen_locker/_constants.py`. The streak store is
`screen_locker/extra_benefits_state.json` (`consecutive_5plus_weeks`,
`last_processed_iso_week`, `extended_early_bird_iso_weeks`; a legacy
`weekly_shutdown_bonus_hours` map is ignored and dropped on the next write).
- The threshold lives in `_extra_benefits.py`: `_BONUS_THRESHOLD = 5` (workouts
per week, for the streak and the early-bird extension). There is no milestone
constant any more.
- **There is no config system** — no argparse, no env vars, no config file. Every
tunable is a module-level constant. An opt-out toggle has no existing home;
for a self-restriction tool, requiring a code edit to disable is arguably
correct, so prefer a constant in `_constants.py` over new state.
- `~/gatelock` is **not** a repo — it is `~/src/utils/gatelock/gatelock/`, consumed as
a pinned git dep, and it holds no workout state.
- Manual workouts were **not** removed: `_manual_workout.py` implements a
rate-limited, evidence-gated subsystem (budget 2 per 7 days, 10 per 30).
- Three UI surfaces exist: the Tk status window (`status_view.py`), a GTK tray
icon at `~/.config/i3/scripts/screen_locker_tray.py` (outside the repo, shells
out to the CLI, refreshes every 60s), and the lock screen itself. No i3blocks
integration exists despite `format_summary_line` being described as one.
REMOVE ME AFTER FINISH

View File

@ -0,0 +1,194 @@
Close the feedback asymmetry in workout_app
## findings (4, ranked by value over effort)
1. **Finishing a workout gets no cue** — while the rest timer gets sound *and*
an 800ms vibration. The app celebrates waiting and ignores achieving. All
infrastructure is already present; only the call is missing.
2. **Set completion has no haptic** — the app's most repeated action, silent.
3. **The fleet's only animation is an inline magic number** —
`Duration(milliseconds: 200)` in `exercise_tile_rows.dart`.
4. **No sound opt-out exists** — the SQLite settings table is ready, but no
public accessor and no `SwitchListTile` anywhere. Step 2 only.
## what
workout_app is the only app in kuhy's fleet that can already play a sound and
vibrate — and it spends that capability on the wrong moment. Verified 2026-08-16:
- **Rest-timer expiry** gets a full cue: `_playBreakEndCue()` in
`lib/screens/workout_screen_session.dart` (~:128-145) plays
`assets/sounds/break_end.mp3` and fires `Vibration.vibrate(duration: 800)`.
- **Finishing an entire workout** gets nothing. `_persistFinishedWorkout()` in
`lib/screens/workout_screen_finish.dart` (~:99-108) ends by showing
`WorkoutSummaryDialog` with no sound, no haptic, no animation.
- **Completing a set** — the core repeated action of the whole app — gets no
haptic either. The `GestureDetector` at `lib/widgets/exercise_tile_rows.dart`
(~:145) toggles state silently.
So the app celebrates *waiting* and ignores *achieving*. Fix the asymmetry: the
moments worth reinforcing are completing a set and finishing a workout.
This app also contains the fleet's **only animation** — a lone
`AnimatedContainer(duration: Duration(milliseconds: 200))` at
`exercise_tile_rows.dart` ~:146-147, with an inline magic number. Replace that
number with a motion token while you are in the file.
## where
Repo: `~/src/screen-locker`. App: `~/src/screen-locker/stronglift_replacement/workout_app`
(a Flutter app nested inside the screen-locker repo; that directory contains **no
Python** — only the app and two design docs).
Primary:
- `lib/widgets/exercise_tile_rows.dart` — set-completion `GestureDetector` ~:145,
the `AnimatedContainer` ~:146-147.
- `lib/screens/workout_screen_finish.dart` — `_persistFinishedWorkout()` ~:99-108.
- `lib/widgets/workout_summary_dialog.dart` — `WorkoutSummaryDialog` ~:10.
- `lib/screens/workout_screen_session.dart` — `_playBreakEndCue()` ~:128-145, the
existing cue to model the new ones on. Note its `.catchError` only
`debugPrint`s (~:131-137).
- `lib/screens/workout_screen.dart` — `_audio = AudioPlayer()` ~:78, `vibration`
import ~:9.
- `lib/widgets/rep_circle.dart` — `RepCircle` ~:34, per-set completion indicator.
Progress/history surfaces (already rich; animate rather than rebuild):
- `lib/screens/history_screen_charts.dart` — `_ProgressStatsCard` ~:71,
**`_StreakRow` ~:139**, `_WeightChart` ~:192.
- `lib/screens/history_screen_painter.dart` — `_ChartPainter` ~:9, `paint()` ~:46,
`shouldRepaint` ~:121 (static; no animation today).
- Streak data: `successStreak`/`failStreak` on `ExerciseState`
(`lib/services/storage_service.dart` ~:42-46).
Settings (for the sound opt-out):
- `lib/screens/settings_screen.dart` — `build()` ~:166, `ListView` ~:174-208.
Parts: `settings_screen_rows.dart`, `settings_screen_sections.dart`,
`settings_screen_exercise_sections.dart`, `settings_screen_thresholds.dart`,
`settings_screen_widget.dart`, `settings_screen_actions.dart`.
**No `SwitchListTile` exists** — model a new section on `_SyncSection`
(`settings_screen_sections.dart` ~:14) or `_OfflineBackupSection`.
- Store: **SQLite**, not SharedPreferences. There is a ready-made key/value table
— schema `lib/services/storage_service_schema.dart` ~:34-36
(`CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, migration
~:64-65), accessors `lib/services/storage_service.dart` ~:122-137
(`_getSetting`/`_setSetting`, both **private** — add a public typed wrapper like
the existing `setLastWorkoutType`). Already backed up/restored via
`lib/services/storage_service_backup.dart` ~:23, :43, :156-163.
Theme: `lib/ui/theme.dart` — local hand-copy, **no `design_system` dependency**
(`buildAppTheme()` ~:12, `AppStatusColors` ~:76, `AppSpacing` ~:111,
`AppRadius` ~:133, `AppTextSize`). Wired at `lib/main.dart` ~:115.
## must
**Step 1 — motion + haptics (no new dependency).**
- Add `HapticFeedback.selectionClick()` on **set completion** — the tap at
`exercise_tile_rows.dart` ~:145. This is the app's most repeated action; it
should feel like flipping a physical switch. Fire on tap, not after the write.
- Add a completion cue when a **workout finishes** — haptic plus a visible
arrival for `WorkoutSummaryDialog`, rather than it appearing instantly.
- Replace the inline `Duration(milliseconds: 200)` at ~:146-147 with the motion
token from prompt 01, transcribed into `lib/ui/theme.dart`.
- Animate the streak/stat numbers on the history screen to their new values
instead of snapping. `_ChartPainter.shouldRepaint` currently returns a static
result — leave the chart itself alone unless animating it is cheap.
- Honour `MediaQuery.of(context).disableAnimations` — durations collapse to zero;
haptics and sound still fire.
- `VIBRATE` is already granted (`android/app/src/main/AndroidManifest.xml` ~:10)
and `vibration: ^3.1.0` is already a dependency. Prefer `HapticFeedback` from
`flutter/services.dart` for short UI taps and reserve the `vibration` package
for the long timed buzz it already does well.
**Step 2 — sound (separate, independently revertable).**
- Add a finish-workout cue asset alongside `assets/sounds/break_end.mp3`
(declared at `pubspec.yaml` ~:52-53 — **this is the fleet's only `assets:`
block**, so you are adding a file, not the block).
- Sound ships **on**, with an opt-out. Add a public
`getSoundEnabled()`/`setSoundEnabled(bool)` on `StorageService` wrapping
`_getSetting`/`_setSetting` (store `'true'`/`'false'`), then a settings section
with a `SwitchListTile`.
- The toggle must silence the **new** cues and the existing break-end cue alike —
one switch, not two behaviours.
**Both steps:**
- must not: **reward the weight or rep number.** The cue fires on *logging a set*
and *finishing a workout*, not on hitting a PB or beating last week. Rewarding
the number pushes toward misreporting; rewarding the record keeps the data
honest. A deloaded set gets the same confirmation as a PB.
- must not: fabricate the streak. `successStreak`/`failStreak` count real
outcomes; display them as they are.
- must not: add a cue to *deleting* a set or workout.
- must not: silently swallow an audio error. The existing `.catchError` →
`debugPrint` at ~:131-137 is the local precedent; do not make it quieter.
- must not: break the **100% coverage gate** — new public methods on
`StorageService` need tests. See the comments at
`settings_screen_sections.dart` ~:1-11.
- must not: exceed the **250-line cap** on any file.
- optional: a subtle near-miss cue on the history screen when a streak is one
workout short of a milestone — legitimate only because the number is true.
## done
1. Completing a set produces an immediate haptic on the phone.
2. Finishing a workout produces a haptic and a visible dialog arrival; step 2
adds a sound.
3. No inline animation duration remains in `exercise_tile_rows.dart`.
4. `cd ~/src/screen-locker/stronglift_replacement/workout_app && flutter analyze` clean.
5. `flutter test` passes **and coverage stays at 100%**.
6. With OS "remove animations" on, the app works and durations are zero.
7. Step 2: the settings toggle flips, persists across restart, and silences both
the new cues and the existing break-end cue.
## verify
**On the phone.**
```
adb devices # confirm 23181JEGR08034
cd ~/src/screen-locker/stronglift_replacement/workout_app
flutter build apk --release
adb install -r build/app/outputs/flutter-apk/app-release.apk
```
Never `flutter install`, never `adb uninstall`, never `pm clear` — those wipe the
SQLite workout history. `adb install -r` preserves it.
Then run a real set on the device: tap a set complete, finish a workout, and
report what the phone actually did. Haptics and sound are physical outcomes —
state what you felt and heard; do not infer them from the code.
## read first
- `lib/screens/workout_screen_session.dart` ~:128-145 — `_playBreakEndCue()`, the
working cue. Your new cues should look like it.
- `lib/widgets/exercise_tile_rows.dart` — the set-completion tap and the fleet's
only `AnimatedContainer`.
- `lib/services/storage_service.dart` ~:122-137 — `_getSetting`/`_setSetting`, and
`setLastWorkoutType` as the public-wrapper pattern.
- `lib/screens/settings_screen_sections.dart` ~:1-11 — the coverage/line-cap
comments, and `_SyncSection` as the section pattern.
- `~/src/utils/unified-design-system/motion.md` — motion/haptic vocabulary from
prompt 01. **Prompt 01 must have run first.**
## context you would otherwise rediscover
- This app has **no `design_system` dependency**; `lib/ui/theme.dart` is a
hand-transcription of the shared tokens. Motion tokens must be transcribed
there, matching the existing convention. Do not add the dependency here.
- `shared_preferences` **is** a dependency but is used only for the device id
(`lib/services/sync_device_id.dart` ~:37-38). Settings go in SQLite.
- Non-motion `Duration`s that are **not** animation and must not be touched:
rest tickers (`workout_screen.dart` ~:91, `workout_screen_breaks.dart` ~:20,
`workout_screen_session.dart` ~:37 — 1s), settings debounces
(`settings_screen.dart` ~:140, :148 — 600ms), `main.dart` ~:56 (20s).
- The app syncs to the screen-locker Python side, which treats
`screen_locker/workout_log.json` as the single source of truth. **Do not change
what counts as a workout** — enforcement depends on it, and it is out of scope.
- The repo enforces a 250-line file cap and bans `# noqa`; ruff runs
`select = ["ALL"]` on the Python side.
REMOVE ME AFTER FINISH

Some files were not shown because too many files have changed in this diff Show More