mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:47:15 +01:00
Compare commits
116 Commits
crdt_sync_
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d6b17fd28 | |||
| ae56037e41 | |||
| eef7af4c86 | |||
| 9daa376254 | |||
| 17e300af0a | |||
| afcc1a47c7 | |||
| 0527bc680d | |||
| 21a0b5ce1c | |||
| 4c1ffd900b | |||
| 5a87868763 | |||
| fe4e34287a | |||
| 2f4f79bd6e | |||
| 51b747a364 | |||
| 89f464828a | |||
| eaf7282ac1 | |||
| 6641d85f05 | |||
| 46ab26fa15 | |||
| f71b0532fd | |||
| 3017bbf220 | |||
| fd81332f27 | |||
| ebedbf4b27 | |||
| d4fca83c9f | |||
| 3121639732 | |||
| 59b33949e4 | |||
| 027e67b197 | |||
| 8dfc4fb55f | |||
| 4fee294aa0 | |||
| 1a74345c40 | |||
| 71ea92f3c2 | |||
| 7f3f79f58d | |||
| 97d043d424 | |||
| 7af6afe51a | |||
| c2165e9fb8 | |||
| 26079c34c2 | |||
| 22fdf2c344 | |||
| de7d5d17eb | |||
| 640985a421 | |||
| 70c8aa8fbb | |||
| ba4dd58545 | |||
| b50e245ceb | |||
| 88e4be5042 | |||
| 737bf624ca | |||
| 716231a7f3 | |||
| 11cb1d81d9 | |||
| e0c9befdeb | |||
| 612b0b3c0a | |||
| f865508eb4 | |||
| 1fb04ce59d | |||
| 6f0070b353 | |||
| aec96eefd3 | |||
| 35569a6335 | |||
| a233268b66 | |||
| 20a825e249 | |||
| 2bbb428f2c | |||
| af3c98a50e | |||
| fd013abf67 | |||
| 0f332c9057 | |||
| 86b2621cd4 | |||
| fe1f4a22ab | |||
| db70ba204d | |||
| 5b36e84793 | |||
| ae29e67e28 | |||
| 4d75ce6d48 | |||
| 637428585c | |||
| c25ae4e0d1 | |||
| 98c013ea3f | |||
| 213537d0ef | |||
| 11c5b2e32b | |||
| c98da93339 | |||
| d45b1086c3 | |||
| 8398ca4823 | |||
| 087ed1c400 | |||
| e852e3ce48 | |||
| 6d80efb50a | |||
| cbcee98d94 | |||
| 82119bdd47 | |||
| d666e47e16 | |||
| 8a3076783e | |||
| 5d11f97093 | |||
| eb1c015315 | |||
| f99b77af89 | |||
| 1e96563522 | |||
| 28d3fb1361 | |||
| bce59127eb | |||
| 1bfacdf51f | |||
| c74815b8ab | |||
| dccfe14ae9 | |||
| 343f015c81 | |||
| decf5824e6 | |||
| 648bde44b1 | |||
| bbb7f0f0c0 | |||
| 4c417472ec | |||
| f9899b9c56 | |||
| 254c8ce240 | |||
| 5be9a22970 | |||
| 888e8ae57d | |||
| a27bbec59a | |||
| 1c9b47e35c | |||
| b3c9611af2 | |||
| def48e346b | |||
| a2e7334dfe | |||
| 99f4e171e3 | |||
| 2c926928dc | |||
| a78c1e971b | |||
| 2531f9eeb3 | |||
| f47ed80584 | |||
| 2a9732a312 | |||
| e06df74caf | |||
| 2d2d8a41f8 | |||
| 6343499ed1 | |||
| ebf7ca624e | |||
| c22b14389a | |||
| c0e902e5cc | |||
| 28c243d066 | |||
| 96a0d8af07 | |||
| b730cffaf4 |
36
.github/workflows/data-transfer-flutter-tests.yml
vendored
Normal file
36
.github/workflows/data-transfer-flutter-tests.yml
vendored
Normal file
@ -0,0 +1,36 @@
|
||||
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
|
||||
73
.github/workflows/focus-project-precommit.yml
vendored
Normal file
73
.github/workflows/focus-project-precommit.yml
vendored
Normal file
@ -0,0 +1,73 @@
|
||||
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
|
||||
34
.github/workflows/focus-project-tests.yml
vendored
Normal file
34
.github/workflows/focus-project-tests.yml
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
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
|
||||
36
.github/workflows/google-account-token-tests.yml
vendored
Normal file
36
.github/workflows/google-account-token-tests.yml
vendored
Normal file
@ -0,0 +1,36 @@
|
||||
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
|
||||
73
.github/workflows/holds-precommit.yml
vendored
Normal file
73
.github/workflows/holds-precommit.yml
vendored
Normal file
@ -0,0 +1,73 @@
|
||||
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
|
||||
34
.github/workflows/holds-tests.yml
vendored
Normal file
34
.github/workflows/holds-tests.yml
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
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
|
||||
73
.github/workflows/leetcode-coach-precommit.yml
vendored
Normal file
73
.github/workflows/leetcode-coach-precommit.yml
vendored
Normal file
@ -0,0 +1,73 @@
|
||||
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
|
||||
34
.github/workflows/leetcode-coach-tests.yml
vendored
Normal file
34
.github/workflows/leetcode-coach-tests.yml
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
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
|
||||
7
.github/workflows/md-naming.yml
vendored
7
.github/workflows/md-naming.yml
vendored
@ -1,6 +1,6 @@
|
||||
name: md-naming
|
||||
|
||||
# The four markdown namespaces, enforced on push as well as at commit time.
|
||||
# The markdown namespaces (README / CLAUDE* / DOCS*, no TODO*), 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,8 +31,3 @@ 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
|
||||
|
||||
2
.github/workflows/music-theory-tests.yml
vendored
2
.github/workflows/music-theory-tests.yml
vendored
@ -28,4 +28,4 @@ jobs:
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: python -m pytest -v
|
||||
run: python -m pytest -v --cov=music_theory
|
||||
|
||||
73
.github/workflows/omniroute_offload-precommit.yml
vendored
Normal file
73
.github/workflows/omniroute_offload-precommit.yml
vendored
Normal file
@ -0,0 +1,73 @@
|
||||
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
|
||||
5
.github/workflows/palette-drift.yml
vendored
5
.github/workflows/palette-drift.yml
vendored
@ -31,3 +31,8 @@ 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
|
||||
|
||||
73
.github/workflows/sql-drills-precommit.yml
vendored
Normal file
73
.github/workflows/sql-drills-precommit.yml
vendored
Normal file
@ -0,0 +1,73 @@
|
||||
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
|
||||
34
.github/workflows/sql-drills-tests.yml
vendored
Normal file
34
.github/workflows/sql-drills-tests.yml
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
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
|
||||
@ -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
|
||||
# (aur-publish/, guard-lib/, crdt_sync_dart/, file_length/, ...).
|
||||
# (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
|
||||
|
||||
# Four markdown namespaces (README / CLAUDE* / DOCS* / TODO*), plus the
|
||||
# rule that a TODO carries "REMOVE ME AFTER FINISH" and nothing else may.
|
||||
# Three markdown namespaces (README / CLAUDE* / DOCS*); TODO*.md is banned
|
||||
# (tasks live in the todo app).
|
||||
# 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,9 +66,14 @@ 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/check_dependency_freshness.sh
|
||||
entry: bash scripts/dep_freshness_changed.sh
|
||||
language: system
|
||||
pass_filenames: true
|
||||
files: >-
|
||||
|
||||
27
.pre-commit-hooks.yaml
Normal file
27
.pre-commit-hooks.yaml
Normal file
@ -0,0 +1,27 @@
|
||||
# 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
|
||||
@ -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`
|
||||
- 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-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 --cov=agent_ready --cov=flutter_web_drive --cov=repo_contract --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 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-gaps: `coverage-gaps coverage.xml`
|
||||
|
||||
15
README.md
15
README.md
@ -42,6 +42,21 @@ 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
|
||||
|
||||
@ -1,153 +0,0 @@
|
||||
# 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
|
||||
@ -1,45 +0,0 @@
|
||||
# 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.
|
||||
@ -1,138 +0,0 @@
|
||||
"""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())
|
||||
@ -1,107 +0,0 @@
|
||||
#!/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
|
||||
}
|
||||
|
||||
@ -1,107 +0,0 @@
|
||||
#!/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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -1,122 +0,0 @@
|
||||
#!/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
|
||||
}
|
||||
|
||||
@ -1,148 +0,0 @@
|
||||
#!/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 "$@"
|
||||
120
crdt-sync/crdt_sync/tests/test_tool_client_probe.py
Normal file
120
crdt-sync/crdt_sync/tests/test_tool_client_probe.py
Normal file
@ -0,0 +1,120 @@
|
||||
# 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
|
||||
186
crdt-sync/crdt_sync/tests/test_tool_desktop_client.py
Normal file
186
crdt-sync/crdt_sync/tests/test_tool_desktop_client.py
Normal file
@ -0,0 +1,186 @@
|
||||
# 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
|
||||
64
crdt-sync/crdt_sync/tests/test_tool_pkce.py
Normal file
64
crdt-sync/crdt_sync/tests/test_tool_pkce.py
Normal file
@ -0,0 +1,64 @@
|
||||
# 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()
|
||||
38
crdt-sync/crdt_sync/tests/test_tool_seeded_apps_todo_mcp.py
Normal file
38
crdt-sync/crdt_sync/tests/test_tool_seeded_apps_todo_mcp.py
Normal file
@ -0,0 +1,38 @@
|
||||
# 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",
|
||||
}
|
||||
156
crdt-sync/crdt_sync/tests/test_tool_verify_desktop_client.py
Normal file
156
crdt-sync/crdt_sync/tests/test_tool_verify_desktop_client.py
Normal file
@ -0,0 +1,156 @@
|
||||
# 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"
|
||||
@ -0,0 +1,125 @@
|
||||
# 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
|
||||
85
crdt-sync/tool/_pkce.py
Normal file
85
crdt-sync/tool/_pkce.py
Normal file
@ -0,0 +1,85 @@
|
||||
# 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
|
||||
@ -67,4 +67,8 @@ 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",
|
||||
)
|
||||
|
||||
@ -44,9 +44,17 @@ 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: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
redirect_uri: str,
|
||||
code_verifier: str = "",
|
||||
) -> str:
|
||||
"""Trade the authorization code for an ID token at Google's endpoint."""
|
||||
"""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 {}
|
||||
try:
|
||||
response = requests.post(
|
||||
_TOKEN_EXCHANGE_URL,
|
||||
@ -56,6 +64,7 @@ def _exchange_code(
|
||||
"client_secret": client_secret,
|
||||
"redirect_uri": redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
**pkce,
|
||||
},
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
158
crdt-sync/tool/client_probe.py
Normal file
158
crdt-sync/tool/client_probe.py
Normal file
@ -0,0 +1,158 @@
|
||||
# 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)]
|
||||
173
crdt-sync/tool/desktop_client.py
Normal file
173
crdt-sync/tool/desktop_client.py
Normal file
@ -0,0 +1,173 @@
|
||||
# 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())
|
||||
127
crdt-sync/tool/register_desktop_client.sh
Executable file
127
crdt-sync/tool/register_desktop_client.sh
Executable file
@ -0,0 +1,127 @@
|
||||
#!/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 "$@"
|
||||
165
crdt-sync/tool/verify_desktop_client.py
Normal file
165
crdt-sync/tool/verify_desktop_client.py
Normal file
@ -0,0 +1,165 @@
|
||||
# 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())
|
||||
@ -118,6 +118,16 @@ 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
|
||||
|
||||
@ -20,4 +20,5 @@ 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';
|
||||
|
||||
@ -19,6 +19,7 @@ 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;
|
||||
@ -33,13 +34,22 @@ 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;
|
||||
@ -47,8 +57,12 @@ 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/$encoded.json',
|
||||
'$_databaseUrl/$full.json',
|
||||
).replace(queryParameters: {'auth': await auth.idToken(), ...query});
|
||||
}
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ 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 {
|
||||
@ -121,6 +122,8 @@ 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,
|
||||
|
||||
@ -7,8 +7,11 @@ 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 writers from colliding. This generalizes
|
||||
/// diet_guard's hand-rolled `LogStorageService` write scheme.
|
||||
/// 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.
|
||||
///
|
||||
/// 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 --
|
||||
@ -20,6 +23,10 @@ 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;
|
||||
@ -31,7 +38,14 @@ class FileLogPersistence implements LogPersistence {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(String text) async {
|
||||
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 {
|
||||
await _file.parent.create(recursive: true);
|
||||
final tmp = File('${_file.path}.$pid.tmp');
|
||||
await tmp.writeAsString(text);
|
||||
|
||||
26
crdt_sync_dart/lib/src/sync_namespace.dart
Normal file
26
crdt_sync_dart/lib/src/sync_namespace.dart
Normal file
@ -0,0 +1,26 @@
|
||||
/// 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)',
|
||||
);
|
||||
}
|
||||
@ -77,10 +77,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
sha256: e3626ae6270383c9e3a549f34c9e4cd26c09ae6a9adaab2bf4737a00340efdc4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
version: "3.0.8"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -2,14 +2,14 @@ name: crdt_sync
|
||||
description: >-
|
||||
Shared CRDT merge scheme + GitHub-Contents-API sync transport for
|
||||
cross-device apps. Pure Dart, no Flutter dependency.
|
||||
version: 0.11.2
|
||||
version: 0.12.1
|
||||
publish_to: 'none'
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
dependencies:
|
||||
crypto: 3.0.7
|
||||
crypto: 3.0.8
|
||||
http: 1.6.0
|
||||
|
||||
dev_dependencies:
|
||||
|
||||
@ -128,5 +128,32 @@ 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}');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
170
crdt_sync_dart/test/sync_namespace_test.dart
Normal file
170
crdt_sync_dart/test/sync_namespace_test.dart
Normal file
@ -0,0 +1,170 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
31
crdt_sync_dart/tool/namespace_probe.dart
Normal file
31
crdt_sync_dart/tool/namespace_probe.dart
Normal file
@ -0,0 +1,31 @@
|
||||
// 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');
|
||||
}
|
||||
@ -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 `google_sign_in`. It takes closures instead.
|
||||
`flutter_secure_storage` or the Android Google plugin. 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,6 +110,11 @@ 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,
|
||||
@ -121,6 +126,7 @@ SyncSettingsScreen(
|
||||
tokenFetcher: () => googleAnyIdToken(
|
||||
serverClientId: kServerClientId,
|
||||
desktopClientId: kDesktopClientId,
|
||||
desktopClientSecret: kDesktopClientSecret,
|
||||
),
|
||||
),
|
||||
googleAvailable: googleAnySignInSupported(
|
||||
@ -132,13 +138,13 @@ SyncSettingsScreen(
|
||||
|
||||
### One tap on every platform
|
||||
|
||||
`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:
|
||||
[`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:
|
||||
|
||||
| Platform | Flow | Entry point |
|
||||
|---|---|---|
|
||||
| Android / iOS | `google_sign_in` plugin, OS account picker | `googleIdToken` |
|
||||
| Android | `google_account_token` plugin, OS account chooser | `googleIdToken` |
|
||||
| Linux / Windows / macOS | OAuth **loopback** + PKCE, system browser | `googleDesktopIdToken` |
|
||||
| Web | none (GIS renders its own button) | falls back to password |
|
||||
|
||||
|
||||
@ -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_sign_in`
|
||||
/// call -- a closure because that plugin is Android/iOS/web only and this
|
||||
/// package must keep working on Linux desktop.
|
||||
/// 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.
|
||||
///
|
||||
/// Returns null when the user dismisses the picker; throws
|
||||
/// [FirebaseAuthError] when Google succeeds but resolves to the wrong uid,
|
||||
@ -171,8 +171,5 @@ 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);
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
/// Picks the right Google sign-in flow for the platform.
|
||||
///
|
||||
/// 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
|
||||
/// 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
|
||||
/// 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_sign_in` plugin: Android and iOS.
|
||||
/// The `google_account_token` plugin: Android.
|
||||
plugin,
|
||||
|
||||
/// OAuth loopback in the system browser: Linux, Windows, macOS.
|
||||
@ -72,9 +72,13 @@ 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,
|
||||
@ -87,7 +91,10 @@ Future<String?> googleAnyIdToken({
|
||||
case GoogleFlow.plugin:
|
||||
return await googleIdToken(serverClientId: serverClientId);
|
||||
case GoogleFlow.desktop:
|
||||
return await googleDesktopIdToken(clientId: desktopClientId);
|
||||
return await googleDesktopIdToken(
|
||||
clientId: desktopClientId,
|
||||
clientSecret: desktopClientSecret,
|
||||
);
|
||||
// coverage:ignore-end
|
||||
case GoogleFlow.none:
|
||||
return null;
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
/// One-tap Google sign-in for desktop, via the OAuth loopback flow.
|
||||
///
|
||||
/// `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.
|
||||
/// 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.
|
||||
///
|
||||
/// Everything decision-shaped lives in `google_pkce.dart` and is covered.
|
||||
/// This file is the I/O: a socket, a browser, one POST.
|
||||
@ -66,8 +66,12 @@ 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,
|
||||
@ -83,11 +87,7 @@ 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,6 +99,7 @@ Future<String?> googleDesktopIdToken({
|
||||
return await _exchange(
|
||||
client: client,
|
||||
clientId: clientId,
|
||||
clientSecret: clientSecret,
|
||||
code: code,
|
||||
port: port,
|
||||
verifier: pair.verifier,
|
||||
@ -136,6 +137,7 @@ 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,
|
||||
@ -144,6 +146,7 @@ Future<String?> _exchange({
|
||||
Uri.parse(kGoogleTokenEndpoint),
|
||||
body: tokenExchangeBody(
|
||||
clientId: clientId,
|
||||
clientSecret: clientSecret,
|
||||
code: code,
|
||||
redirectPort: port,
|
||||
verifier: verifier,
|
||||
|
||||
@ -136,13 +136,21 @@ 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',
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
/// Platform gate for the programmatic Google sign-in flow.
|
||||
///
|
||||
/// 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()`.
|
||||
/// 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.
|
||||
library;
|
||||
|
||||
export 'google_platform_io.dart'
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
/// Whether the programmatic Google sign-in flow exists on this platform.
|
||||
///
|
||||
/// 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.
|
||||
/// 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.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:google_account_token/google_account_token.dart';
|
||||
|
||||
/// True on the platforms shipping the programmatic plugin flow.
|
||||
bool get googleSignInSupported => Platform.isAndroid || Platform.isIOS;
|
||||
/// 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;
|
||||
|
||||
/// Whether the loopback sign-in flow can run: it needs a bindable socket and
|
||||
/// a system browser, which every `dart:io` platform has.
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
/// One-tap Google sign-in: the `google_sign_in` plugin call, owned once.
|
||||
/// One-tap Google sign-in: the `google_account_token` 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 `google_sign_in` does not exist. It only ever receives a
|
||||
/// systemd, where the Android plugin 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,10 +11,8 @@
|
||||
/// 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_sign_in/google_sign_in.dart';
|
||||
import 'package:google_account_token/google_account_token.dart' as account;
|
||||
|
||||
/// Whether this build can sign in with Google programmatically.
|
||||
///
|
||||
@ -33,56 +31,21 @@ 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 picker: cancelling is
|
||||
/// an ordinary outcome, and the caller falls back to the password path.
|
||||
/// 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".
|
||||
///
|
||||
/// [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`.
|
||||
/// [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`.
|
||||
///
|
||||
/// [signInFn] exists for tests, which cannot reach the plugin's platform
|
||||
/// channel; when supplied it replaces the plugin entirely.
|
||||
/// [signInFn] exists for tests; when supplied it replaces the plugin entirely.
|
||||
Future<String?> googleIdToken({
|
||||
required String serverClientId,
|
||||
Future<String?> Function()? signInFn,
|
||||
}) async {
|
||||
if (signInFn != null) return await signInFn();
|
||||
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
|
||||
return await account.googleIdToken(serverClientId: serverClientId);
|
||||
}
|
||||
|
||||
@ -61,19 +61,19 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: crdt_sync_dart
|
||||
ref: "crdt_sync_dart-v0.11.2"
|
||||
resolved-ref: "1b5515fd8b1192207896c409212483aeb09704ea"
|
||||
ref: "crdt_sync_dart-v0.12.0"
|
||||
resolved-ref: "59b33949e4a130bb76c77e7434ddf4bbf8cde3a3"
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.11.2"
|
||||
version: "0.12.0"
|
||||
crypto:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
sha256: e3626ae6270383c9e3a549f34c9e4cd26c09ae6a9adaab2bf4737a00340efdc4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
version: "3.0.8"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -177,54 +177,15 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
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:
|
||||
google_account_token:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
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"
|
||||
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"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -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.3.4
|
||||
version: 0.5.1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
@ -13,9 +13,9 @@ dependencies:
|
||||
crdt_sync:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: crdt_sync_dart-v0.11.2
|
||||
ref: crdt_sync_dart-v0.12.1
|
||||
path: crdt_sync_dart
|
||||
crypto: 3.0.7
|
||||
crypto: 3.0.8
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_secure_storage: 11.2.0
|
||||
@ -23,10 +23,15 @@ dependencies:
|
||||
# consuming apps can test their own sync wiring without rebuilding it.
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
# The one-tap Google plugin. Android/iOS/web only -- there is no Linux
|
||||
# implementation -- so desktop reaches Google through the loopback flow in
|
||||
# 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
|
||||
# google_desktop_sign_in.dart instead. See google_platform.dart.
|
||||
google_sign_in: 7.2.0
|
||||
google_account_token:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: google_account_token-v0.1.0
|
||||
path: google_account_token
|
||||
http: 1.6.0
|
||||
shared_preferences: 2.5.6
|
||||
# Opens the system browser for the desktop loopback sign-in.
|
||||
|
||||
@ -1,11 +1,23 @@
|
||||
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() {
|
||||
// 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.
|
||||
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);
|
||||
});
|
||||
|
||||
group('desktopSignInSupported', () {
|
||||
test('is true on this host once a desktop client id exists', () {
|
||||
expect(desktopSignInSupported('desktop-cid'), isTrue);
|
||||
@ -90,4 +102,35 @@ 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,6 +54,22 @@ 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',
|
||||
@ -99,9 +115,7 @@ 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),
|
||||
),
|
||||
|
||||
@ -142,6 +142,29 @@ 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');
|
||||
|
||||
@ -1,18 +1,39 @@
|
||||
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('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.
|
||||
test('is false on a desktop host even with a client id', () {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
|
||||
expect(googleSignInSupported('some-client-id'), isFalse);
|
||||
});
|
||||
});
|
||||
@ -40,4 +61,30 @@ 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
6
data_transfer_flutter/.gitignore
vendored
Normal file
6
data_transfer_flutter/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
.dart_tool/
|
||||
.packages
|
||||
build/
|
||||
pubspec.lock
|
||||
coverage/
|
||||
*.iml
|
||||
66
data_transfer_flutter/.pre-commit-config.yaml
Normal file
66
data_transfer_flutter/.pre-commit-config.yaml
Normal file
@ -0,0 +1,66 @@
|
||||
# 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]
|
||||
38
data_transfer_flutter/README.md
Normal file
38
data_transfer_flutter/README.md
Normal file
@ -0,0 +1,38 @@
|
||||
# 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`.
|
||||
38
data_transfer_flutter/analysis_options.yaml
Normal file
38
data_transfer_flutter/analysis_options.yaml
Normal file
@ -0,0 +1,38 @@
|
||||
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
|
||||
7
data_transfer_flutter/lib/data_transfer_flutter.dart
Normal file
7
data_transfer_flutter/lib/data_transfer_flutter.dart
Normal file
@ -0,0 +1,7 @@
|
||||
/// 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';
|
||||
118
data_transfer_flutter/lib/src/envelope.dart
Normal file
118
data_transfer_flutter/lib/src/envelope.dart
Normal file
@ -0,0 +1,118 @@
|
||||
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);
|
||||
}
|
||||
27
data_transfer_flutter/lib/src/json_type_group.dart
Normal file
27
data_transfer_flutter/lib/src/json_type_group.dart
Normal file
@ -0,0 +1,27 @@
|
||||
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],
|
||||
);
|
||||
}
|
||||
32
data_transfer_flutter/lib/src/platform.dart
Normal file
32
data_transfer_flutter/lib/src/platform.dart
Normal file
@ -0,0 +1,32 @@
|
||||
/// 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();
|
||||
}
|
||||
3
data_transfer_flutter/lib/src/platform_default.dart
Normal file
3
data_transfer_flutter/lib/src/platform_default.dart
Normal file
@ -0,0 +1,3 @@
|
||||
export 'platform_io.dart'
|
||||
if (dart.library.js_interop) 'platform_web.dart'
|
||||
show createDefaultPlatform;
|
||||
120
data_transfer_flutter/lib/src/platform_io.dart
Normal file
120
data_transfer_flutter/lib/src/platform_io.dart
Normal file
@ -0,0 +1,120 @@
|
||||
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
|
||||
43
data_transfer_flutter/lib/src/platform_web.dart
Normal file
43
data_transfer_flutter/lib/src/platform_web.dart
Normal file
@ -0,0 +1,43 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
65
data_transfer_flutter/lib/src/transfer.dart
Normal file
65
data_transfer_flutter/lib/src/transfer.dart
Normal file
@ -0,0 +1,65 @@
|
||||
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();
|
||||
28
data_transfer_flutter/pubspec.yaml
Normal file
28
data_transfer_flutter/pubspec.yaml
Normal file
@ -0,0 +1,28 @@
|
||||
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:
|
||||
32
data_transfer_flutter/scripts/check_coverage.sh
Executable file
32
data_transfer_flutter/scripts/check_coverage.sh
Executable file
@ -0,0 +1,32 @@
|
||||
#!/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 "$@"
|
||||
182
data_transfer_flutter/test/envelope_test.dart
Normal file
182
data_transfer_flutter/test/envelope_test.dart
Normal file
@ -0,0 +1,182 @@
|
||||
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>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
162
data_transfer_flutter/test/io_platform_test.dart
Normal file
162
data_transfer_flutter/test/io_platform_test.dart
Normal file
@ -0,0 +1,162 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
43
data_transfer_flutter/test/support/fake_platform.dart
Normal file
43
data_transfer_flutter/test/support/fake_platform.dart
Normal file
@ -0,0 +1,43 @@
|
||||
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;
|
||||
}
|
||||
113
data_transfer_flutter/test/transfer_test.dart
Normal file
113
data_transfer_flutter/test/transfer_test.dart
Normal file
@ -0,0 +1,113 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -64,6 +64,11 @@ 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.
|
||||
@ -86,6 +91,8 @@ 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
|
||||
@ -208,7 +215,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. TachiyomiSY cannot take moko-resources 0.27.0 (a 300-file accessor
|
||||
# rewrite that breaks every future upstream string) before jobobby04 does.
|
||||
# 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.
|
||||
UPSTREAM_PREFIX: Final = "upstream:"
|
||||
GITHUB_RAW: Final = "https://raw.githubusercontent.com/{repo}/HEAD/{path}"
|
||||
|
||||
@ -21,6 +21,7 @@ from dep_freshness._tables import (
|
||||
DEFAULT_CACHE_DIR,
|
||||
GITCOMMIT,
|
||||
GITTAG,
|
||||
TTL_ENV,
|
||||
TTL_GITTAG_SECONDS,
|
||||
TTL_SECONDS,
|
||||
)
|
||||
@ -33,7 +34,14 @@ def cache_dir() -> Path:
|
||||
|
||||
|
||||
def ttl_for(ecosystem: str) -> int:
|
||||
"""Git refs move rarely and cost a subprocess, so they cache longer."""
|
||||
"""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)
|
||||
return TTL_GITTAG_SECONDS if ecosystem in (GITTAG, GITCOMMIT) else TTL_SECONDS
|
||||
|
||||
|
||||
|
||||
@ -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`; TachiyomiSY has three), so the suffix is what is matched.
|
||||
`*.versions.toml`; a large app can have several), 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 TachiyomiSY) and is skipped: the BOM's own entry is the pin that counts.
|
||||
in a typical app) 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.
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"""JitPack artifacts pinned to a commit: "newest" is the default branch's HEAD.
|
||||
|
||||
Seven of TachiyomiSY's dependencies are `com.github.<owner>:<repo>` at a
|
||||
Some Android builds depend on `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
|
||||
|
||||
@ -16,7 +16,9 @@ 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.
|
||||
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.
|
||||
|
||||
`<latest>` and `<release>` in the metadata are not trusted: AndroidX writes
|
||||
`1.2.0-alpha03` into `<latest>` routinely. The full `<versions>` list goes
|
||||
@ -32,7 +34,12 @@ from __future__ import annotations
|
||||
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from dep_freshness._tables import GITHUB_REMOTE, MAVEN_REPOS
|
||||
from dep_freshness._tables import (
|
||||
GITHUB_REMOTE,
|
||||
JITPACK_GROUP_PREFIX,
|
||||
JITPACK_REPO,
|
||||
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
|
||||
@ -69,6 +76,15 @@ 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:
|
||||
@ -79,5 +95,5 @@ def latest(name: str) -> str | None:
|
||||
if body is not None:
|
||||
seen.extend(versions_in(body))
|
||||
if not seen:
|
||||
seen.extend(github_tags(name))
|
||||
seen.extend(_jitpack_versions(name))
|
||||
return reference(seen) or newest_per_generation(seen)
|
||||
|
||||
@ -147,6 +147,47 @@ 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):
|
||||
|
||||
@ -12,6 +12,7 @@ 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
|
||||
@ -139,3 +140,16 @@ 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
|
||||
|
||||
@ -45,12 +45,19 @@ 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"])
|
||||
@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",
|
||||
])
|
||||
def test_ranges_are_not_exact_pins(raw):
|
||||
assert exact_pin(raw) is None
|
||||
|
||||
@ -61,6 +68,13 @@ 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")
|
||||
|
||||
@ -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 --
|
||||
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.
|
||||
a library that rewrites every string accessor across 300 files of the fork
|
||||
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
|
||||
|
||||
@ -160,10 +160,12 @@ 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.
|
||||
# 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.
|
||||
EXACT = re.compile(
|
||||
r"^\s*(?:==\s*)?(?:[A-Za-z][A-Za-z_.-]*?-(?=v?\d))?"
|
||||
r"v?\d+(?:\.\d+)*(?:[-+][0-9A-Za-z.-]+)?\s*$"
|
||||
r"v?\d+(?:\.\d+)*(?:(?:a|b|rc)\d+)?(?:\.post\d+)?(?:\.dev\d+)?"
|
||||
r"(?:[-+][0-9A-Za-z.-]+)?\s*$"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ 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';
|
||||
|
||||
199
design_system/lib/src/fit_grid.dart
Normal file
199
design_system/lib/src/fit_grid.dart
Normal file
@ -0,0 +1,199 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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.2.1
|
||||
version: 0.3.1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
221
design_system/test/fit_grid_test.dart
Normal file
221
design_system/test/fit_grid_test.dart
Normal file
@ -0,0 +1,221 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@ -1,165 +0,0 @@
|
||||
# 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.**
|
||||
@ -1,140 +0,0 @@
|
||||
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
|
||||
@ -1,139 +0,0 @@
|
||||
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
|
||||
@ -1,170 +0,0 @@
|
||||
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
|
||||
@ -1,173 +0,0 @@
|
||||
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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user