mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-12 08:31:01 +01:00
Compare commits
83 Commits
focus-proj
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| eb9bdc5796 | |||
| 307248b60b | |||
| 33791da6ff | |||
| eebeb0b510 | |||
| 2813a88db6 | |||
| cc09e56cd2 | |||
| 51e562a764 | |||
| b0fad2b2e5 | |||
| c83d41a594 | |||
| 2bbfbd9439 | |||
| 2d341dd41e | |||
| 3084080d74 | |||
| 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 | |||
| 1bfacdf51f | |||
| 99f4e171e3 | |||
| 2c926928dc | |||
| a78c1e971b | |||
| 2531f9eeb3 | |||
| f47ed80584 | |||
| 2a9732a312 | |||
| e06df74caf | |||
| 2d2d8a41f8 | |||
| 6343499ed1 | |||
| ebf7ca624e | |||
| c22b14389a | |||
| c0e902e5cc | |||
| 28c243d066 | |||
| 96a0d8af07 | |||
| b730cffaf4 |
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
|
||||
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
|
||||
@ -16,3 +16,4 @@ overwrite it with `repo_contract/templates/check_repo_contract.shim.sh`.
|
||||
- lint: `ruff check agent_ready coverage_gaps flutter_web_drive repo_contract`
|
||||
- coverage: `PYTHONPATH=. python -m pytest -q agent_ready/tests flutter_web_drive/tests repo_contract/tests unified-design-system/tests --cov=agent_ready --cov=flutter_web_drive --cov=repo_contract --cov=conformance --cov=conformance_diff --cov-branch --cov-report=xml --cov-fail-under=100`
|
||||
- coverage-gaps: `coverage-gaps coverage.xml`
|
||||
- deploy: `git -C ~/src/utils pull --ff-only && ~/src/utils/scripts/deploy.sh` (installs the sickdays CLI + launcher from the live checkout, then exits 1 unless every released library's version tag is on origin and matches HEAD and the live `sickdays` runs the text-free checkout; `--verify-only` for the health check alone)
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
# Encrypt crdt_sync payloads end to end
|
||||
|
||||
## what
|
||||
Firebase RTDB stores every crdt_sync record in plaintext, so Google can read
|
||||
all app data. Encrypt record payloads on the device before push and decrypt
|
||||
after pull, so RTDB stays a blind relay (the "ownership" meaning of
|
||||
local-first: hologram.page/blog/local-first-means-three-different-things).
|
||||
|
||||
## open decisions (ask kuhy before coding)
|
||||
- Key source: derived from the shared crdt-sync password (Argon2id) or a
|
||||
random key stored in the OS keystore and copied to new devices.
|
||||
- Migration: re-encrypt existing logs in place, or new prefix + one-time copy.
|
||||
- Scope: payload only (keep HLC/ids clear for merge) or whole record.
|
||||
- diet_guard's `export_keys` document (random data-export keys, synced as a
|
||||
keyring) sits in plaintext in RTDB, so Firebase can read them: it must move
|
||||
under E2E too, with its key source decided here first.
|
||||
- tutor uses its own RTDB client (`~/src/tutor/app/lib/sync/rtdb_client.dart`):
|
||||
in or out of scope.
|
||||
|
||||
## steps
|
||||
1. Add an encrypt/decrypt seam in `crdt_sync_dart/lib/src/remote_store.dart`
|
||||
(push/pull), AEAD (XChaCha20-Poly1305), version byte per record.
|
||||
2. Property test: decrypt(encrypt(r)) == r; merge result identical with and
|
||||
without encryption (see property-and-differential-tests skill).
|
||||
3. Read path accepts plaintext and v1 records during migration.
|
||||
4. Roll out app by app (todo first), bump pins, verify on the phone.
|
||||
|
||||
## done
|
||||
`dart test` in `crdt_sync_dart` passes, and
|
||||
`firebase database:get /<prefix> | grep -c '"title"'` returns 0 for every
|
||||
migrated app.
|
||||
|
||||
## verify
|
||||
Pixel 6a + desktop todo: edit on one, see it on the other; raw RTDB JSON shows
|
||||
only ciphertext.
|
||||
|
||||
## read first
|
||||
- `crdt_sync_dart/lib/src/sync.dart`, `log.dart`, `remote_store.dart`
|
||||
- `crdt_sync_flutter/lib/src/bootstrap.dart`
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
@ -1,93 +0,0 @@
|
||||
# TODO — pylint `--fail-under=10` everywhere, and clear what that surfaces
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
|
||||
## what
|
||||
|
||||
Two pylint hooks (`leetcode-guard`, `steam-backlog-enforcer`) 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 both to
|
||||
`10`, and fix what that surfaces. Every other site already runs at `10`, so
|
||||
this is adopting an existing standard, not inventing one.
|
||||
|
||||
## what is left (updated 2026-11-02)
|
||||
|
||||
Done and verified (`pre-commit run pylint --all-files`, exit 0, each at
|
||||
`--fail-under=10`): `control-panel`, `screen-locker` (both hooks),
|
||||
`testsAndMisc/meta`, `utils/crdt-sync`, `utils/freedays`, `utils/gatelock`,
|
||||
`utils/music_theory`.
|
||||
|
||||
Still at `--fail-under=8.0`:
|
||||
|
||||
| Repo | Site |
|
||||
|---|---|
|
||||
| `leetcode-guard` | `.pre-commit-config.yaml:126` |
|
||||
| `steam-backlog-enforcer` | `.pre-commit-config.yaml:126` (ignore its `.claude/worktrees/` copies) |
|
||||
|
||||
Both live outside `utils`, so this file belongs to whichever session works
|
||||
those repos. The method is the fleet standard: pylint grades source, ruff
|
||||
grades tests. Copy the `[tool.pylint]` block (tests basename ignore,
|
||||
`init-hook = "import sys; sys.path.insert(0, '.')"`) from `wake-alarm` /
|
||||
`diet-guard`, bump to `10`, fix the residue in source. Read the `must`
|
||||
rules below first.
|
||||
|
||||
## 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 --exclude-dir=worktrees --include=.pre-commit-config.yaml --include=.pylintrc \
|
||||
-E 'fail-under=(8|9)' ~/src ~/praca_magisterska 2>/dev/null \
|
||||
| grep -v cov-fail-under
|
||||
|
||||
# 2. both remaining packages pass its own pylint hook
|
||||
for d in ~/src/leetcode-guard ~/src/steam-backlog-enforcer; 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 "$@"
|
||||
@ -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
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
# Make FileLogPersistence.write safe under concurrent calls
|
||||
|
||||
## what
|
||||
`FileLogPersistence.write` (lib/src/io_store.dart) writes to a tmp file named
|
||||
`<file>.<pid>.tmp` and renames it over the target. The name is per process,
|
||||
not per call, so two overlapping writes in one process (per-keystroke upserts
|
||||
in the todo app) share one tmp path: the first rename moves it away and the
|
||||
second throws an unhandled `PathNotFoundException` on
|
||||
`rename` of `todo_notes.json.<pid>.tmp`. Seen in the todo app during
|
||||
concurrent per-keystroke upserts.
|
||||
Fix by serializing writes (a chained Future per instance, last write wins)
|
||||
or by using a unique tmp name per call; keep the atomic rename.
|
||||
|
||||
## done
|
||||
A regression test in test/ fires 50 overlapping `write()` calls on one
|
||||
instance, all complete without error, and the file then holds the text of a
|
||||
call that was issued (not a torn mix). `dart test` in this package exits 0
|
||||
with coverage still 100%.
|
||||
|
||||
## verify
|
||||
`cd ~/src/utils/crdt_sync_dart && dart test --coverage=coverage`; then bump
|
||||
the todo app's `crdt_sync` ref and run its full suite.
|
||||
|
||||
## read first
|
||||
- lib/src/io_store.dart
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
@ -2,6 +2,13 @@
|
||||
library;
|
||||
|
||||
export 'src/device.dart';
|
||||
export 'src/e2e_audit.dart';
|
||||
export 'src/e2e_cipher.dart';
|
||||
export 'src/e2e_envelope.dart';
|
||||
export 'src/e2e_key.dart';
|
||||
export 'src/e2e_migration.dart';
|
||||
export 'src/e2e_open.dart';
|
||||
export 'src/encrypting_store.dart';
|
||||
export 'src/firebase_account_provisioning.dart';
|
||||
export 'src/firebase_auth_error.dart';
|
||||
export 'src/firebase_auth_rest.dart';
|
||||
@ -20,4 +27,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';
|
||||
|
||||
23
crdt_sync_dart/lib/src/e2e_audit.dart
Normal file
23
crdt_sync_dart/lib/src/e2e_audit.dart
Normal file
@ -0,0 +1,23 @@
|
||||
import 'e2e_cipher.dart';
|
||||
import 'e2e_envelope.dart';
|
||||
import 'firebase_keys.dart';
|
||||
|
||||
/// Plaintext leaves in a decoded RTDB dump; empty once an app is migrated.
|
||||
/// Decodes because RTDB serves blobs as escaped strings that defeat grep.
|
||||
List<String> plaintextBlobPaths(Object? tree) {
|
||||
final found = <String>[];
|
||||
void walk(Object? node, String path) {
|
||||
if (node is String) {
|
||||
if (E2eCipher.isPlaintext(node) && !isE2eEnvelope(node)) found.add(path);
|
||||
} else if (node is Map) {
|
||||
for (final MapEntry(:key, :value) in node.entries) {
|
||||
if (key == 'e2e' && value is String) continue;
|
||||
final segment = decodeKey(key as String);
|
||||
walk(value, path.isEmpty ? segment : '$path/$segment');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(tree, '');
|
||||
return found;
|
||||
}
|
||||
97
crdt_sync_dart/lib/src/e2e_cipher.dart
Normal file
97
crdt_sync_dart/lib/src/e2e_cipher.dart
Normal file
@ -0,0 +1,97 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
|
||||
const int e2eFormatVersion = 1;
|
||||
|
||||
const _nonceLength = 24;
|
||||
const _tagLength = 16;
|
||||
|
||||
/// A FormatException so `syncLog` skips the peer instead of aborting the tick.
|
||||
class E2eDecryptError extends FormatException {
|
||||
E2eDecryptError(super.message);
|
||||
|
||||
@override
|
||||
String toString() => 'E2eDecryptError: $message';
|
||||
}
|
||||
|
||||
/// XChaCha20-Poly1305 per blob: base64 `[version][nonce][ct][tag]`, with the
|
||||
/// version byte and remote path as AAD so blobs cannot be swapped.
|
||||
///
|
||||
/// [acceptPlaintext] reads un-migrated peers; turn it off after migration.
|
||||
class E2eCipher {
|
||||
E2eCipher(List<int> key, {this.acceptPlaintext = true})
|
||||
: _key = SecretKey(List<int>.unmodifiable(key)) {
|
||||
if (key.length != 32) {
|
||||
throw ArgumentError.value(key.length, 'key', 'must be 32 bytes');
|
||||
}
|
||||
}
|
||||
|
||||
final SecretKey _key;
|
||||
final bool acceptPlaintext;
|
||||
|
||||
static final _aead = Xchacha20.poly1305Aead();
|
||||
|
||||
static bool isPlaintext(String stored) {
|
||||
final trimmed = stored.trimLeft();
|
||||
return trimmed.startsWith('{') || trimmed.startsWith('[');
|
||||
}
|
||||
|
||||
Future<String> seal(String plaintext, {required String path}) async {
|
||||
final box = await _aead.encrypt(
|
||||
utf8.encode(plaintext),
|
||||
secretKey: _key,
|
||||
nonce: _aead.newNonce(),
|
||||
aad: _aad(e2eFormatVersion, path),
|
||||
);
|
||||
return base64.encode([
|
||||
e2eFormatVersion,
|
||||
...box.nonce,
|
||||
...box.cipherText,
|
||||
...box.mac.bytes,
|
||||
]);
|
||||
}
|
||||
|
||||
Future<String> open(String stored, {required String path}) async {
|
||||
if (isPlaintext(stored)) {
|
||||
if (acceptPlaintext) return stored;
|
||||
throw E2eDecryptError('plaintext blob at $path after migration');
|
||||
}
|
||||
final List<int> bytes;
|
||||
try {
|
||||
bytes = base64.decode(stored);
|
||||
} on FormatException {
|
||||
throw E2eDecryptError('blob at $path is neither JSON nor base64');
|
||||
}
|
||||
if (bytes.isEmpty) throw E2eDecryptError('empty blob at $path');
|
||||
final version = bytes.first;
|
||||
if (version != e2eFormatVersion) {
|
||||
throw E2eDecryptError(
|
||||
'blob at $path uses format $version; update the app to read it',
|
||||
);
|
||||
}
|
||||
if (bytes.length < 1 + _nonceLength + _tagLength) {
|
||||
throw E2eDecryptError('blob at $path is truncated');
|
||||
}
|
||||
final box = SecretBox(
|
||||
bytes.sublist(1 + _nonceLength, bytes.length - _tagLength),
|
||||
nonce: bytes.sublist(1, 1 + _nonceLength),
|
||||
mac: Mac(bytes.sublist(bytes.length - _tagLength)),
|
||||
);
|
||||
try {
|
||||
final clear = await _aead.decrypt(
|
||||
box,
|
||||
secretKey: _key,
|
||||
aad: _aad(version, path),
|
||||
);
|
||||
return utf8.decode(clear);
|
||||
} on SecretBoxAuthenticationError {
|
||||
throw E2eDecryptError('blob at $path failed authentication');
|
||||
}
|
||||
}
|
||||
|
||||
static List<int> _aad(int version, String path) => [
|
||||
version,
|
||||
...utf8.encode(path),
|
||||
];
|
||||
}
|
||||
123
crdt_sync_dart/lib/src/e2e_envelope.dart
Normal file
123
crdt_sync_dart/lib/src/e2e_envelope.dart
Normal file
@ -0,0 +1,123 @@
|
||||
/// The per-record wire envelope: record ids, the tombstone and the newest HLC
|
||||
/// stay in clear so the relay can order and inspect; every field name and
|
||||
/// value is sealed. Merge still runs on the decrypted [Log].
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'e2e_cipher.dart';
|
||||
import 'hlc.dart';
|
||||
|
||||
const int _envelopeVersion = 1;
|
||||
|
||||
/// Whether [text] is an envelope (explicit `"v"` integer), not legacy plaintext.
|
||||
///
|
||||
/// A legacy log maps record ids to objects, so an id named `v` still maps to
|
||||
/// a map, never an int.
|
||||
bool isE2eEnvelope(String text) {
|
||||
try {
|
||||
final json = jsonDecode(text);
|
||||
return json is Map && json['v'] is int;
|
||||
} on FormatException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
String _aad(
|
||||
String path,
|
||||
String id,
|
||||
bool deleted,
|
||||
String? deletedHlc,
|
||||
String? h,
|
||||
) => '$path#$id#${deleted ? 1 : 0}#${deletedHlc ?? ''}#${h ?? ''}';
|
||||
|
||||
/// Seals a log (the `logToJson` shape) into an envelope. Text that is not
|
||||
/// log-shaped is sealed whole, so nothing is ever pushed in clear.
|
||||
Future<String> sealLogText(
|
||||
String text,
|
||||
E2eCipher cipher, {
|
||||
required String path,
|
||||
}) async {
|
||||
final Map<String, dynamic> raw;
|
||||
try {
|
||||
raw = (jsonDecode(text) as Map).cast<String, dynamic>();
|
||||
for (final entry in raw.entries) {
|
||||
final rec = entry.value as Map;
|
||||
if (rec['id'] != entry.key || rec['fields'] is! Map) {
|
||||
throw const FormatException('not a log');
|
||||
}
|
||||
}
|
||||
} on Object {
|
||||
return jsonEncode({
|
||||
'v': _envelopeVersion,
|
||||
'b': await cipher.seal(text, path: path),
|
||||
});
|
||||
}
|
||||
final records = <String, Object?>{};
|
||||
for (final entry in raw.entries) {
|
||||
final rec = (entry.value as Map).cast<String, dynamic>();
|
||||
final fields = (rec['fields'] as Map).cast<String, dynamic>();
|
||||
Hlc? newest;
|
||||
for (final field in fields.values) {
|
||||
final hlc = Hlc.fromStr((field as List)[1] as String);
|
||||
if (newest == null || hlc > newest) newest = hlc;
|
||||
}
|
||||
final deleted = rec['deleted'] as bool? ?? false;
|
||||
final deletedHlc = rec['deleted_hlc'] as String?;
|
||||
final h = newest?.toStr();
|
||||
records[entry.key] = {
|
||||
'd': deleted,
|
||||
'dh': deletedHlc,
|
||||
'h': h,
|
||||
'p': await cipher.seal(
|
||||
jsonEncode(fields),
|
||||
path: _aad(path, entry.key, deleted, deletedHlc, h),
|
||||
),
|
||||
};
|
||||
}
|
||||
return jsonEncode({'v': _envelopeVersion, 'r': records});
|
||||
}
|
||||
|
||||
/// Opens an envelope back to the `logToJson` shape. Legacy plaintext is
|
||||
/// returned as-is while the cipher accepts it.
|
||||
Future<String> openLogText(
|
||||
String text,
|
||||
E2eCipher cipher, {
|
||||
required String path,
|
||||
}) async {
|
||||
if (!isE2eEnvelope(text)) {
|
||||
if (cipher.acceptPlaintext) return text;
|
||||
throw E2eDecryptError('plaintext blob at $path after migration');
|
||||
}
|
||||
final json = (jsonDecode(text) as Map).cast<String, dynamic>();
|
||||
if (json['v'] != _envelopeVersion) {
|
||||
throw E2eDecryptError(
|
||||
'blob at $path uses format ${json['v']}; update the app to read it',
|
||||
);
|
||||
}
|
||||
final whole = json['b'];
|
||||
if (whole is String) return cipher.open(whole, path: path);
|
||||
final records = json['r'];
|
||||
if (records is! Map) throw E2eDecryptError('envelope at $path is mis-shaped');
|
||||
final out = <String, Object?>{};
|
||||
for (final entry in records.entries) {
|
||||
final id = entry.key as String;
|
||||
final rec = entry.value;
|
||||
if (rec is! Map || rec['p'] is! String || rec['d'] is! bool) {
|
||||
throw E2eDecryptError('record $id at $path is mis-shaped');
|
||||
}
|
||||
final deletedHlc = rec['dh'] as String?;
|
||||
final h = rec['h'] as String?;
|
||||
final clear = await cipher.open(
|
||||
rec['p'] as String,
|
||||
path: _aad(path, id, rec['d'] as bool, deletedHlc, h),
|
||||
);
|
||||
out[id] = {
|
||||
'id': id,
|
||||
'fields': jsonDecode(clear),
|
||||
'deleted': rec['d'],
|
||||
'deleted_hlc': deletedHlc,
|
||||
};
|
||||
}
|
||||
return jsonEncode(out);
|
||||
}
|
||||
254
crdt_sync_dart/lib/src/e2e_key.dart
Normal file
254
crdt_sync_dart/lib/src/e2e_key.dart
Normal file
@ -0,0 +1,254 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
|
||||
import 'e2e_cipher.dart';
|
||||
import 'encrypting_store.dart';
|
||||
import 'remote_store.dart';
|
||||
|
||||
const _saltLength = 16;
|
||||
const _headerVersion = 1;
|
||||
|
||||
/// Argon2id cost, stored in the header so it can be raised later.
|
||||
class Argon2Params {
|
||||
const Argon2Params({
|
||||
required this.memoryKiB,
|
||||
required this.iterations,
|
||||
required this.parallelism,
|
||||
});
|
||||
|
||||
/// OWASP's floor (19 MiB, t=2); higher stalls the Flutter-web desktop build.
|
||||
static const recommended = Argon2Params(
|
||||
memoryKiB: 19456,
|
||||
iterations: 2,
|
||||
parallelism: 1,
|
||||
);
|
||||
|
||||
final int memoryKiB;
|
||||
final int iterations;
|
||||
final int parallelism;
|
||||
}
|
||||
|
||||
class E2eHeaderError extends FormatException {
|
||||
E2eHeaderError(super.message);
|
||||
|
||||
@override
|
||||
String toString() => 'E2eHeaderError: $message';
|
||||
}
|
||||
|
||||
class E2eWrongPassphraseError implements Exception {
|
||||
@override
|
||||
String toString() => 'E2eWrongPassphraseError: wrong sync passphrase';
|
||||
}
|
||||
|
||||
/// The clear header's path: a sibling of the device dirs, like `revs`.
|
||||
String defaultE2ePath(String pathPrefix) {
|
||||
final cut = pathPrefix.lastIndexOf('/');
|
||||
return cut < 0 ? '$pathPrefix/e2e' : '${pathPrefix.substring(0, cut)}/e2e';
|
||||
}
|
||||
|
||||
class _Header {
|
||||
_Header(this.salt, this.params, this.check);
|
||||
|
||||
factory _Header.parse(String text) {
|
||||
final Object? json;
|
||||
try {
|
||||
json = jsonDecode(text);
|
||||
} on FormatException {
|
||||
throw E2eHeaderError('e2e header is not JSON');
|
||||
}
|
||||
if (json is! Map<String, dynamic>) {
|
||||
throw E2eHeaderError('e2e header is not an object');
|
||||
}
|
||||
if (json['v'] != _headerVersion) {
|
||||
throw E2eHeaderError('e2e header v${json['v']}; update the app');
|
||||
}
|
||||
try {
|
||||
if (json['kdf'] != 'argon2id') throw const FormatException();
|
||||
return _Header(
|
||||
base64.decode(json['salt'] as String),
|
||||
Argon2Params(
|
||||
memoryKiB: json['m'] as int,
|
||||
iterations: json['t'] as int,
|
||||
parallelism: json['p'] as int,
|
||||
),
|
||||
json['check'] as String,
|
||||
);
|
||||
} on Object {
|
||||
throw E2eHeaderError('e2e header is malformed');
|
||||
}
|
||||
}
|
||||
|
||||
final List<int> salt;
|
||||
final Argon2Params params;
|
||||
final String check;
|
||||
|
||||
String encode() => jsonEncode({
|
||||
'v': _headerVersion,
|
||||
'kdf': 'argon2id',
|
||||
'salt': base64.encode(salt),
|
||||
'm': params.memoryKiB,
|
||||
't': params.iterations,
|
||||
'p': params.parallelism,
|
||||
'check': check,
|
||||
});
|
||||
}
|
||||
|
||||
/// A 32-byte master key plus its HKDF subkeys for sealing, revisions and the
|
||||
/// passphrase check. Cache [bytes] in the OS keystore, never in RTDB.
|
||||
class E2eKey {
|
||||
E2eKey._(this.bytes, this._enc, this._rev, this._check);
|
||||
|
||||
static Future<E2eKey> fromBytes(List<int> bytes) {
|
||||
if (bytes.length != 32) {
|
||||
throw ArgumentError.value(bytes.length, 'bytes', 'must be 32 bytes');
|
||||
}
|
||||
return _expand(List<int>.unmodifiable(bytes));
|
||||
}
|
||||
|
||||
static Future<E2eKey> _expand(List<int> master) async {
|
||||
Future<List<int>> sub(String label) async {
|
||||
final key = await Hkdf(hmac: Hmac.sha256(), outputLength: 32).deriveKey(
|
||||
secretKey: SecretKey(master),
|
||||
info: utf8.encode('crdt_sync e2e $label'),
|
||||
);
|
||||
return key.bytes;
|
||||
}
|
||||
|
||||
return E2eKey._(
|
||||
master,
|
||||
await sub('enc'),
|
||||
SecretKey(await sub('rev')),
|
||||
SecretKey(await sub('check')),
|
||||
);
|
||||
}
|
||||
|
||||
final List<int> bytes;
|
||||
final List<int> _enc;
|
||||
final SecretKey _rev;
|
||||
final SecretKey _check;
|
||||
|
||||
E2eCipher cipher({bool acceptPlaintext = true}) =>
|
||||
E2eCipher(_enc, acceptPlaintext: acceptPlaintext);
|
||||
|
||||
/// Keyed `revisionOf`: a published revision cannot confirm a guessed log.
|
||||
Future<String> revision(String text) async {
|
||||
final mac = await Hmac.sha256().calculateMac(
|
||||
utf8.encode(text),
|
||||
secretKey: _rev,
|
||||
);
|
||||
return mac.bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
Future<String> _checkFor(List<int> salt) async {
|
||||
final mac = await Hmac.sha256().calculateMac(salt, secretKey: _check);
|
||||
return base64.encode(mac.bytes);
|
||||
}
|
||||
}
|
||||
|
||||
Future<E2eKey> _derive(
|
||||
String passphrase,
|
||||
List<int> salt,
|
||||
Argon2Params params,
|
||||
) async {
|
||||
final key = await Argon2id(
|
||||
parallelism: params.parallelism,
|
||||
memory: params.memoryKiB,
|
||||
iterations: params.iterations,
|
||||
hashLength: 32,
|
||||
).deriveKeyFromPassword(password: passphrase, nonce: salt);
|
||||
return E2eKey._expand(await key.extractBytes());
|
||||
}
|
||||
|
||||
Future<_Header?> _readHeader(RemoteStore store, String path) async {
|
||||
final text = await store.getFileText(path);
|
||||
return text == null ? null : _Header.parse(text);
|
||||
}
|
||||
|
||||
/// Derives this app's key from [passphrase], creating the header on first use.
|
||||
///
|
||||
/// Throws [E2eWrongPassphraseError] on a failed check and [E2eHeaderError]
|
||||
/// for an unreadable header, which is never overwritten.
|
||||
Future<E2eKey> unlockE2e({
|
||||
required RemoteStore store,
|
||||
required String pathPrefix,
|
||||
required String passphrase,
|
||||
Argon2Params params = Argon2Params.recommended,
|
||||
Random? random,
|
||||
String? e2ePath,
|
||||
}) async {
|
||||
if (passphrase.isEmpty) {
|
||||
throw ArgumentError.value('', 'passphrase', 'must not be empty');
|
||||
}
|
||||
final path = e2ePath ?? defaultE2ePath(pathPrefix);
|
||||
var header = await _readHeader(store, path);
|
||||
if (header == null) {
|
||||
final rng = random ?? Random.secure();
|
||||
final salt = List<int>.generate(_saltLength, (_) => rng.nextInt(256));
|
||||
final key = await _derive(passphrase, salt, params);
|
||||
final encoded = _Header(salt, params, await key._checkFor(salt)).encode();
|
||||
await store.putFileText(path, encoded, message: path);
|
||||
// Two devices setting up at once: the last write wins, so re-read.
|
||||
final stored = await store.getFileText(path) ?? encoded;
|
||||
if (stored == encoded) return key;
|
||||
header = _Header.parse(stored);
|
||||
}
|
||||
final key = await _derive(passphrase, header.salt, header.params);
|
||||
if (await key._checkFor(header.salt) != header.check) {
|
||||
throw E2eWrongPassphraseError();
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
sealed class E2eState {}
|
||||
|
||||
/// No header and no key: this app has never been encrypted.
|
||||
final class E2eOff extends E2eState {}
|
||||
|
||||
enum E2eLockReason { needsPassphrase, wrongKey, badHeader }
|
||||
|
||||
/// Encryption is on, but this device must not sync until it has the key.
|
||||
final class E2eLocked extends E2eState {
|
||||
E2eLocked(this.reason);
|
||||
|
||||
final E2eLockReason reason;
|
||||
}
|
||||
|
||||
final class E2eReady extends E2eState {
|
||||
E2eReady(this.store);
|
||||
|
||||
final EncryptingStore store;
|
||||
}
|
||||
|
||||
Future<E2eState> openE2e({
|
||||
required RemoteStore store,
|
||||
required String pathPrefix,
|
||||
E2eKey? key,
|
||||
String? e2ePath,
|
||||
String? revsPath,
|
||||
bool acceptPlaintext = true,
|
||||
}) async {
|
||||
final _Header? header;
|
||||
try {
|
||||
header = await _readHeader(store, e2ePath ?? defaultE2ePath(pathPrefix));
|
||||
} on E2eHeaderError {
|
||||
return E2eLocked(E2eLockReason.badHeader);
|
||||
}
|
||||
if (key == null) {
|
||||
return header == null ? E2eOff() : E2eLocked(E2eLockReason.needsPassphrase);
|
||||
}
|
||||
if (header == null || await key._checkFor(header.salt) != header.check) {
|
||||
return E2eLocked(E2eLockReason.wrongKey);
|
||||
}
|
||||
return E2eReady(
|
||||
EncryptingStore(
|
||||
store,
|
||||
key,
|
||||
pathPrefix: pathPrefix,
|
||||
revsPath: revsPath,
|
||||
e2ePath: e2ePath,
|
||||
acceptPlaintext: acceptPlaintext,
|
||||
),
|
||||
);
|
||||
}
|
||||
180
crdt_sync_dart/lib/src/e2e_migration.dart
Normal file
180
crdt_sync_dart/lib/src/e2e_migration.dart
Normal file
@ -0,0 +1,180 @@
|
||||
/// The one-time copy from a plaintext prefix to `<prefix>-e2e`.
|
||||
///
|
||||
/// Re-encrypting in place is rejected: an old build still writing the old
|
||||
/// prefix would be broken mid-migration. Instead the old prefix stays
|
||||
/// untouched until [deletePlaintextPrefix] verifies the copy.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'e2e_key.dart';
|
||||
import 'encrypting_store.dart';
|
||||
import 'log.dart';
|
||||
import 'record.dart';
|
||||
import 'remote_store.dart';
|
||||
import 'store.dart';
|
||||
|
||||
/// Where an app's encrypted data lives, derived from its old plaintext prefix.
|
||||
class E2eLayout {
|
||||
const E2eLayout._(this.prefix, this.revsPath, this.e2ePath);
|
||||
|
||||
/// `<oldPrefix>-e2e`, with revisions and header apart from the old ones so
|
||||
/// old builds neither see nor clobber them.
|
||||
factory E2eLayout.of(String oldPrefix) {
|
||||
final cut = oldPrefix.lastIndexOf('/');
|
||||
final parent = cut < 0 ? '' : '${oldPrefix.substring(0, cut)}/';
|
||||
final prefix = '$oldPrefix-e2e';
|
||||
return E2eLayout._(prefix, '${parent}revs-e2e', defaultE2ePath(prefix));
|
||||
}
|
||||
|
||||
final String prefix;
|
||||
final String revsPath;
|
||||
final String e2ePath;
|
||||
}
|
||||
|
||||
class MigrationReport {
|
||||
const MigrationReport(this.copied, this.skipped);
|
||||
|
||||
/// Blobs written (new or changed) this run.
|
||||
final int copied;
|
||||
|
||||
/// Source blobs unreadable as a log, relative path.
|
||||
final List<String> skipped;
|
||||
}
|
||||
|
||||
Future<Map<String, String>> _listBlobs(RemoteStore store, String root) async {
|
||||
if (store is BulkMapReader) {
|
||||
return (store as BulkMapReader).getStringMap(root);
|
||||
}
|
||||
final out = <String, String>{};
|
||||
for (final entry in await store.listDirectory(root)) {
|
||||
final children = await store.listDirectory('$root/$entry');
|
||||
if (children.isEmpty) {
|
||||
final text = await store.getFileText('$root/$entry');
|
||||
if (text != null) out[entry] = text;
|
||||
continue;
|
||||
}
|
||||
for (final child in children) {
|
||||
final text = await store.getFileText('$root/$entry/$child');
|
||||
if (text != null) out['$entry/$child'] = text;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
EncryptingStore _dest(RemoteStore store, E2eKey key, E2eLayout layout) =>
|
||||
EncryptingStore(
|
||||
store,
|
||||
key,
|
||||
pathPrefix: layout.prefix,
|
||||
revsPath: layout.revsPath,
|
||||
e2ePath: layout.e2ePath,
|
||||
);
|
||||
|
||||
/// Order-independent signature of a log, to tell "same records" cheaply.
|
||||
String _signature(Log log) {
|
||||
final ids = log.keys.toList()..sort();
|
||||
return jsonEncode([
|
||||
for (final id in ids)
|
||||
[
|
||||
id,
|
||||
log[id]!.deleted,
|
||||
log[id]!.deletedHlc?.toStr(),
|
||||
[
|
||||
for (final name in (log[id]!.fields.keys.toList()..sort()))
|
||||
[
|
||||
name,
|
||||
jsonEncode(log[id]!.fields[name]!.$1),
|
||||
log[id]!.fields[name]!.$2.toStr(),
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
Log? _parse(String text) {
|
||||
try {
|
||||
return logFromJson(text);
|
||||
} on FormatException {
|
||||
return null;
|
||||
} on TypeError {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies every readable plaintext log under [fromPrefix] into the encrypted
|
||||
/// prefix, merging into what is already there. Idempotent: an unchanged
|
||||
/// destination is not rewritten. Never touches [fromPrefix].
|
||||
Future<MigrationReport> copyToEncrypted({
|
||||
required RemoteStore store,
|
||||
required E2eKey key,
|
||||
required String fromPrefix,
|
||||
required E2eLayout layout,
|
||||
}) async {
|
||||
final dest = _dest(store, key, layout);
|
||||
var copied = 0;
|
||||
final skipped = <String>[];
|
||||
final blobs = await _listBlobs(store, fromPrefix);
|
||||
for (final MapEntry(key: rel, value: text) in blobs.entries) {
|
||||
final source = _parse(text);
|
||||
if (source == null) {
|
||||
skipped.add(rel);
|
||||
continue;
|
||||
}
|
||||
final path = '${layout.prefix}/$rel';
|
||||
final existing = await dest.getFileText(path);
|
||||
final current = existing == null ? null : _parse(existing);
|
||||
final merged = mergeLogs(current ?? <String, Record>{}, source);
|
||||
if (current != null && _signature(merged) == _signature(current)) continue;
|
||||
await dest.putFileText(path, logToJson(merged), message: 'e2e copy');
|
||||
copied++;
|
||||
}
|
||||
return MigrationReport(copied, skipped);
|
||||
}
|
||||
|
||||
/// True when every readable plaintext blob is contained in the encrypted copy
|
||||
/// and none is unreadable.
|
||||
Future<bool> verifyCopy({
|
||||
required RemoteStore store,
|
||||
required E2eKey key,
|
||||
required String fromPrefix,
|
||||
required E2eLayout layout,
|
||||
}) async {
|
||||
final dest = _dest(store, key, layout);
|
||||
for (final MapEntry(key: rel, value: text) in (await _listBlobs(
|
||||
store,
|
||||
fromPrefix,
|
||||
)).entries) {
|
||||
final source = _parse(text);
|
||||
if (source == null) return false;
|
||||
final existing = await dest.getFileText('${layout.prefix}/$rel');
|
||||
final current = existing == null ? null : _parse(existing);
|
||||
if (current == null) return false;
|
||||
if (_signature(mergeLogs(current, source)) != _signature(current)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Deletes the plaintext prefix, only after [verifyCopy] passes. Returns
|
||||
/// whether it deleted.
|
||||
Future<bool> deletePlaintextPrefix({
|
||||
required RemoteStore store,
|
||||
required E2eKey key,
|
||||
required String fromPrefix,
|
||||
required E2eLayout layout,
|
||||
}) async {
|
||||
if (!await verifyCopy(
|
||||
store: store,
|
||||
key: key,
|
||||
fromPrefix: fromPrefix,
|
||||
layout: layout,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
for (final rel in (await _listBlobs(store, fromPrefix)).keys) {
|
||||
await store.deleteFile('$fromPrefix/$rel', message: 'e2e cleanup');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
64
crdt_sync_dart/lib/src/e2e_open.dart
Normal file
64
crdt_sync_dart/lib/src/e2e_open.dart
Normal file
@ -0,0 +1,64 @@
|
||||
/// Opens the encrypted store for an app whose old plaintext prefix is
|
||||
/// [oldPrefix]; data lives on `E2eLayout.of(oldPrefix)`.
|
||||
///
|
||||
/// [readPassphrase] supplies the shared crdt-sync passphrase from the OS
|
||||
/// keystore, so the open is prompt-free. Fail closed: a missing or wrong
|
||||
/// passphrase, or an unreadable header, returns [E2eLocked] and the caller
|
||||
/// must not sync. On success the old plaintext prefix is copied over once
|
||||
/// per call (idempotent; picks up writes from builds not yet migrated) and
|
||||
/// is never deleted here.
|
||||
library;
|
||||
|
||||
import 'dart:developer';
|
||||
|
||||
import 'e2e_key.dart';
|
||||
import 'e2e_migration.dart';
|
||||
import 'remote_store.dart';
|
||||
|
||||
/// Keystore entry name apps store the shared passphrase under.
|
||||
const String kE2ePassphraseStorageKey = 'crdt_sync_e2e_passphrase';
|
||||
|
||||
Future<E2eState> openEncryptedStore({
|
||||
required RemoteStore store,
|
||||
required String oldPrefix,
|
||||
required Future<String?> Function() readPassphrase,
|
||||
Argon2Params params = Argon2Params.recommended,
|
||||
}) async {
|
||||
final passphrase = await readPassphrase();
|
||||
if (passphrase == null || passphrase.isEmpty) {
|
||||
return E2eLocked(E2eLockReason.needsPassphrase);
|
||||
}
|
||||
final layout = E2eLayout.of(oldPrefix);
|
||||
final E2eKey key;
|
||||
try {
|
||||
key = await unlockE2e(
|
||||
store: store,
|
||||
pathPrefix: layout.prefix,
|
||||
passphrase: passphrase,
|
||||
params: params,
|
||||
e2ePath: layout.e2ePath,
|
||||
);
|
||||
} on E2eWrongPassphraseError {
|
||||
return E2eLocked(E2eLockReason.wrongKey);
|
||||
} on E2eHeaderError {
|
||||
return E2eLocked(E2eLockReason.badHeader);
|
||||
}
|
||||
try {
|
||||
await copyToEncrypted(
|
||||
store: store,
|
||||
key: key,
|
||||
fromPrefix: oldPrefix,
|
||||
layout: layout,
|
||||
);
|
||||
} on Object catch (error, stackTrace) {
|
||||
// The copy is retried next open; sync itself must not stop for it.
|
||||
log('e2e copy failed', level: 900, error: error, stackTrace: stackTrace);
|
||||
}
|
||||
return await openE2e(
|
||||
store: store,
|
||||
pathPrefix: layout.prefix,
|
||||
key: key,
|
||||
e2ePath: layout.e2ePath,
|
||||
revsPath: layout.revsPath,
|
||||
);
|
||||
}
|
||||
80
crdt_sync_dart/lib/src/encrypting_store.dart
Normal file
80
crdt_sync_dart/lib/src/encrypting_store.dart
Normal file
@ -0,0 +1,80 @@
|
||||
import 'e2e_cipher.dart';
|
||||
import 'e2e_envelope.dart';
|
||||
import 'e2e_key.dart';
|
||||
import 'remote_store.dart';
|
||||
import 'sync.dart';
|
||||
|
||||
/// A [RemoteStore] that seals every blob under `pathPrefix` (nested
|
||||
/// `<device>/<file>` or flat `<node>.json`) except the header, as a
|
||||
/// per-record envelope (ids and HLCs clear, names and values opaque), and
|
||||
/// keys every revision under `revsPath`, so the remote is a blind relay.
|
||||
///
|
||||
/// Wrap the outermost store (above a `MirrorStore`) so every backend gets
|
||||
/// ciphertext. Other paths pass through unchanged.
|
||||
class EncryptingStore implements RemoteStore, BulkMapReader {
|
||||
EncryptingStore(
|
||||
this._inner,
|
||||
this._key, {
|
||||
required String pathPrefix,
|
||||
String? revsPath,
|
||||
String? e2ePath,
|
||||
bool acceptPlaintext = true,
|
||||
}) : _cipher = _key.cipher(acceptPlaintext: acceptPlaintext),
|
||||
_prefix = pathPrefix,
|
||||
_revs = revsPath ?? defaultRevsPath(pathPrefix),
|
||||
_header = e2ePath ?? defaultE2ePath(pathPrefix);
|
||||
|
||||
final RemoteStore _inner;
|
||||
final E2eKey _key;
|
||||
final E2eCipher _cipher;
|
||||
final String _prefix;
|
||||
final String _revs;
|
||||
final String _header;
|
||||
|
||||
bool _isRev(String path) => path.startsWith('$_revs/');
|
||||
|
||||
bool _isLog(String path) =>
|
||||
!_isRev(path) && path != _header && path.startsWith('$_prefix/');
|
||||
|
||||
@override
|
||||
Future<String?> getFileText(String path) async {
|
||||
final text = await _inner.getFileText(path);
|
||||
if (text == null || !_isLog(path)) return text;
|
||||
return openLogText(text, _cipher, path: path);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> putFileText(
|
||||
String path,
|
||||
String text, {
|
||||
required String message,
|
||||
}) async {
|
||||
final stored = _isLog(path)
|
||||
? await sealLogText(text, _cipher, path: path)
|
||||
: _isRev(path)
|
||||
? await _key.revision(text)
|
||||
: text;
|
||||
await _inner.putFileText(path, stored, message: message);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> getStringMap(String path) async {
|
||||
if (_inner case final BulkMapReader reader) {
|
||||
return reader.getStringMap(path);
|
||||
}
|
||||
return const {};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> listDirectory(String path) => _inner.listDirectory(path);
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String path, {String message = ''}) =>
|
||||
_inner.deleteFile(path, message: message);
|
||||
|
||||
@override
|
||||
Future<bool> canAccessRemote() => _inner.canAccessRemote();
|
||||
|
||||
@override
|
||||
void close() => _inner.close();
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
import 'e2e_cipher.dart';
|
||||
import 'log.dart';
|
||||
import 'record.dart';
|
||||
import 'remote_store.dart';
|
||||
@ -44,6 +45,9 @@ String revisionOf(String encodedLog) =>
|
||||
/// treated as this device's own rather than as a peer's, so its
|
||||
/// pre-migration log is not pulled back and re-merged every tick. Pass null
|
||||
/// once that path has been reclaimed.
|
||||
///
|
||||
/// [onPeerSkipped] reports each peer skipped as unreadable, e.g. an
|
||||
/// [E2eDecryptError] from a wrong key or an app that needs updating.
|
||||
Future<Log> syncLog({
|
||||
required RemoteStore client,
|
||||
required String deviceId,
|
||||
@ -56,6 +60,7 @@ Future<Log> syncLog({
|
||||
SyncStateStore? stateStore,
|
||||
String? revsPath,
|
||||
String? legacyDeviceId,
|
||||
void Function(String deviceId, Object error)? onPeerSkipped,
|
||||
}) async {
|
||||
final revs = revsPath ?? defaultRevsPath(pathPrefix);
|
||||
final state = await stateStore?.load() ?? const SyncState();
|
||||
@ -80,15 +85,21 @@ Future<Log> syncLog({
|
||||
seenRevs[otherDeviceId] = remoteRev;
|
||||
continue;
|
||||
}
|
||||
final text = await client.getFileText(
|
||||
'$pathPrefix/$otherDeviceId/$filename',
|
||||
);
|
||||
final String? text;
|
||||
try {
|
||||
text = await client.getFileText('$pathPrefix/$otherDeviceId/$filename');
|
||||
} on E2eDecryptError catch (error) {
|
||||
onPeerSkipped?.call(otherDeviceId, error);
|
||||
continue;
|
||||
}
|
||||
if (text == null) continue;
|
||||
try {
|
||||
mergedLog = mergeLogs(mergedLog, decode(text));
|
||||
} on FormatException {
|
||||
} on FormatException catch (error) {
|
||||
onPeerSkipped?.call(otherDeviceId, error);
|
||||
continue;
|
||||
} on TypeError {
|
||||
} on TypeError catch (error) {
|
||||
onPeerSkipped?.call(otherDeviceId, error);
|
||||
continue;
|
||||
}
|
||||
// Only remembered once the merge succeeded: a corrupt push must be
|
||||
|
||||
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)',
|
||||
);
|
||||
}
|
||||
@ -41,6 +41,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
cli_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -81,6 +89,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.8"
|
||||
cryptography:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cryptography
|
||||
sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.0"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -97,6 +121,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
glados:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: glados
|
||||
sha256: c39446c0c4fe83a6ee6ee4fdee5a1afa27050619c7df390bf4c3b77b6f1acb33
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.7"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -2,7 +2,7 @@ name: crdt_sync
|
||||
description: >-
|
||||
Shared CRDT merge scheme + GitHub-Contents-API sync transport for
|
||||
cross-device apps. Pure Dart, no Flutter dependency.
|
||||
version: 0.11.3
|
||||
version: 0.13.1
|
||||
publish_to: 'none'
|
||||
|
||||
environment:
|
||||
@ -10,8 +10,10 @@ environment:
|
||||
|
||||
dependencies:
|
||||
crypto: 3.0.8
|
||||
cryptography: 2.9.0
|
||||
http: 1.6.0
|
||||
|
||||
dev_dependencies:
|
||||
glados: 1.1.7
|
||||
lints: 6.1.0
|
||||
test: 1.33.0
|
||||
|
||||
@ -40,9 +40,7 @@ void main() {
|
||||
// Nothing listens on this port, so the GET throws and the `on Exception`
|
||||
// path returns null -- while the `finally` still closes the client the
|
||||
// function created for itself.
|
||||
final account = await accountFromWrapper(
|
||||
Uri.parse('http://127.0.0.1:1'),
|
||||
);
|
||||
final account = await accountFromWrapper(Uri.parse('http://127.0.0.1:1'));
|
||||
expect(account, isNull);
|
||||
});
|
||||
|
||||
@ -50,8 +48,10 @@ void main() {
|
||||
final mock = http_testing.MockClient(
|
||||
(_) async => http.Response(jsonEncode({}), 404),
|
||||
);
|
||||
expect(await accountFromWrapper(Uri.parse('http://x'), client: mock),
|
||||
isNull);
|
||||
expect(
|
||||
await accountFromWrapper(Uri.parse('http://x'), client: mock),
|
||||
isNull,
|
||||
);
|
||||
// Still usable: the function must not close a client it did not create.
|
||||
expect((await mock.get(Uri.parse('http://x'))).statusCode, 404);
|
||||
});
|
||||
@ -106,27 +106,65 @@ void main() {
|
||||
expect(await store.read(), isNull);
|
||||
});
|
||||
|
||||
test('read returns null when the file cannot be read', () async {
|
||||
// A mode-000 file still passes existsSync, so the guard does not fire
|
||||
// and readAsString throws PathAccessException (a FileSystemException).
|
||||
// A directory would NOT work here: File.existsSync() is false for one,
|
||||
// so read() would return early down the guard path instead.
|
||||
final file = File('${dir.path}/unreadable.json')..writeAsStringSync('{}');
|
||||
final chmod = Process.runSync('chmod', ['000', file.path]);
|
||||
expect(chmod.exitCode, 0);
|
||||
addTearDown(() => Process.runSync('chmod', ['644', file.path]));
|
||||
test(
|
||||
'read returns null when the file cannot be read',
|
||||
() async {
|
||||
// A mode-000 file still passes existsSync, so the guard does not fire
|
||||
// and readAsString throws PathAccessException (a FileSystemException).
|
||||
// A directory would NOT work here: File.existsSync() is false for one,
|
||||
// so read() would return early down the guard path instead.
|
||||
final file = File('${dir.path}/unreadable.json')
|
||||
..writeAsStringSync('{}');
|
||||
final chmod = Process.runSync('chmod', ['000', file.path]);
|
||||
expect(chmod.exitCode, 0);
|
||||
addTearDown(() => Process.runSync('chmod', ['644', file.path]));
|
||||
|
||||
expect(await FileLogPersistence(file).read(), isNull);
|
||||
},
|
||||
// root ignores the permission bits, so the exception never fires.
|
||||
skip: Platform.environment['USER'] == 'root'
|
||||
? 'permission bits do not apply to root'
|
||||
: null);
|
||||
expect(await FileLogPersistence(file).read(), isNull);
|
||||
},
|
||||
// root ignores the permission bits, so the exception never fires.
|
||||
skip: Platform.environment['USER'] == 'root'
|
||||
? 'permission bits do not apply to root'
|
||||
: null,
|
||||
);
|
||||
|
||||
test('round-trips what it wrote', () async {
|
||||
final store = FileLogPersistence(File('${dir.path}/log.json'));
|
||||
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}');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
56
crdt_sync_dart/test/e2e_audit_test.dart
Normal file
56
crdt_sync_dart/test/e2e_audit_test.dart
Normal file
@ -0,0 +1,56 @@
|
||||
/// The done check: find plaintext blobs in a raw RTDB dump.
|
||||
///
|
||||
/// RTDB returns each blob as a JSON string, so its quotes arrive escaped and
|
||||
/// `grep -c '"title"'` prints 0 even on plaintext. The audit decodes instead.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// Shape copied from a live `GET /<root>.json` (scratch data).
|
||||
const _livePlaintextDump =
|
||||
r'{"notes":{"pc":{"log~2Ejson":"{\"n2\":{\"id\":\"n2\",\"fields\":'
|
||||
r'{\"title\":[\"call mum\",\"1970-01-01T00:00:01.001Z-0000-pc\"]},'
|
||||
r'\"deleted\":false,\"deleted_hlc\":null}}"}},"revs":{"pc":"ab12"}}';
|
||||
|
||||
void main() {
|
||||
test('the grep done check misses live plaintext; the audit does not', () {
|
||||
expect('"title"'.allMatches(_livePlaintextDump), isEmpty);
|
||||
expect(plaintextBlobPaths(jsonDecode(_livePlaintextDump)), [
|
||||
'notes/pc/log.json',
|
||||
]);
|
||||
});
|
||||
|
||||
test('sealed blobs, revs and the clear e2e header pass', () async {
|
||||
final sealed = await E2eCipher(
|
||||
List<int>.filled(32, 1),
|
||||
).seal('{"title":"x"}', path: 'notes/pc/log.json');
|
||||
final dump = {
|
||||
'e2e': '{"v":1,"kdf":"argon2id"}',
|
||||
'notes': {
|
||||
'pc': {'log~2Ejson': sealed},
|
||||
},
|
||||
'revs': {'pc': 'ab12'},
|
||||
};
|
||||
expect(plaintextBlobPaths(dump), isEmpty);
|
||||
});
|
||||
|
||||
test('every plaintext leaf is reported, with keys unescaped', () {
|
||||
final dump = {
|
||||
'a': {
|
||||
'x~2Ey': '[1]',
|
||||
'b': {'c': ' {"k":1}'},
|
||||
},
|
||||
'n': 3,
|
||||
'nil': null,
|
||||
};
|
||||
expect(plaintextBlobPaths(dump), unorderedEquals(['a/x.y', 'a/b/c']));
|
||||
});
|
||||
|
||||
test('a root that is itself plaintext, or empty, is handled', () {
|
||||
expect(plaintextBlobPaths('{"a":1}'), ['']);
|
||||
expect(plaintextBlobPaths(null), isEmpty);
|
||||
});
|
||||
}
|
||||
130
crdt_sync_dart/test/e2e_cipher_test.dart
Normal file
130
crdt_sync_dart/test/e2e_cipher_test.dart
Normal file
@ -0,0 +1,130 @@
|
||||
/// The record-blob AEAD: what makes the remote a blind relay.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:glados/glados.dart' hide expect, group, test, setUp;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
const _path = 'todo-sync/notes/phone/log.json';
|
||||
|
||||
E2eCipher _cipher([int fill = 7, bool acceptPlaintext = true]) =>
|
||||
E2eCipher(List<int>.filled(32, fill), acceptPlaintext: acceptPlaintext);
|
||||
|
||||
void main() {
|
||||
Glados(any.letterOrDigits, ExploreConfig(numRuns: 60)).test(
|
||||
'open(seal(x)) == x for any text',
|
||||
(text) async {
|
||||
final cipher = _cipher();
|
||||
final sealed = await cipher.seal('{"t":"$text"}', path: _path);
|
||||
expect(await cipher.open(sealed, path: _path), '{"t":"$text"}');
|
||||
},
|
||||
);
|
||||
|
||||
test('sealed text contains no plaintext and is not JSON', () async {
|
||||
final sealed = await _cipher().seal('{"title":"secret"}', path: _path);
|
||||
expect(sealed, isNot(contains('title')));
|
||||
expect(sealed, isNot(contains('secret')));
|
||||
expect(() => jsonDecode(sealed), throwsFormatException);
|
||||
expect(E2eCipher.isPlaintext(sealed), isFalse);
|
||||
});
|
||||
|
||||
test('two seals of the same text differ (random nonce)', () async {
|
||||
final cipher = _cipher();
|
||||
expect(
|
||||
await cipher.seal('{}', path: _path),
|
||||
isNot(await cipher.seal('{}', path: _path)),
|
||||
);
|
||||
});
|
||||
|
||||
test('wire format starts with the version byte', () async {
|
||||
final bytes = base64.decode(await _cipher().seal('{}', path: _path));
|
||||
expect(bytes.first, e2eFormatVersion);
|
||||
expect(bytes.length, 1 + 24 + 2 + 16);
|
||||
});
|
||||
|
||||
test('plaintext JSON passes through during migration', () async {
|
||||
expect(await _cipher().open(' {"a":1}', path: _path), ' {"a":1}');
|
||||
expect(await _cipher().open('[1]', path: _path), '[1]');
|
||||
expect(E2eCipher.isPlaintext('\n{'), isTrue);
|
||||
});
|
||||
|
||||
test('plaintext is refused once migration is over', () async {
|
||||
expect(
|
||||
() => _cipher(7, false).open('{"a":1}', path: _path),
|
||||
throwsA(isA<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
|
||||
group('rejects, as a FormatException so syncLog skips the peer', () {
|
||||
test('a wrong key', () async {
|
||||
final sealed = await _cipher(1).seal('{}', path: _path);
|
||||
expect(
|
||||
() => _cipher(2).open(sealed, path: _path),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('a blob moved to another path', () async {
|
||||
final sealed = await _cipher().seal('{}', path: _path);
|
||||
expect(
|
||||
() => _cipher().open(sealed, path: 'todo-sync/notes/pc/log.json'),
|
||||
throwsA(isA<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('a flipped ciphertext byte', () async {
|
||||
final bytes = base64.decode(await _cipher().seal('{}', path: _path));
|
||||
bytes[30] ^= 1;
|
||||
expect(
|
||||
() => _cipher().open(base64.encode(bytes), path: _path),
|
||||
throwsA(isA<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('an unknown version byte', () async {
|
||||
final bytes = base64.decode(await _cipher().seal('{}', path: _path));
|
||||
bytes[0] = 9;
|
||||
expect(
|
||||
() => _cipher().open(base64.encode(bytes), path: _path),
|
||||
throwsA(
|
||||
isA<E2eDecryptError>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('update the app'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('text that is not base64', () {
|
||||
expect(
|
||||
() => _cipher().open('not base64 !!', path: _path),
|
||||
throwsA(isA<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('a blob too short to hold nonce and tag', () {
|
||||
expect(
|
||||
() => _cipher().open(base64.encode([1, 2, 3]), path: _path),
|
||||
throwsA(isA<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('an empty blob', () {
|
||||
expect(
|
||||
() => _cipher().open('', path: _path),
|
||||
throwsA(isA<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('a key that is not 32 bytes is a programming error', () {
|
||||
expect(() => E2eCipher(const [1, 2]), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('E2eDecryptError reads as itself', () {
|
||||
expect(E2eDecryptError('x').toString(), 'E2eDecryptError: x');
|
||||
});
|
||||
}
|
||||
156
crdt_sync_dart/test/e2e_envelope_test.dart
Normal file
156
crdt_sync_dart/test/e2e_envelope_test.dart
Normal file
@ -0,0 +1,156 @@
|
||||
/// Per-record envelope: ids and HLCs stay clear, field names and values are
|
||||
/// opaque, and merge results are identical with and without encryption.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:glados/glados.dart' hide expect, group, test, setUp;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'e2e_fakes.dart';
|
||||
|
||||
const _path = 'app/notes-e2e/phone/log.json';
|
||||
|
||||
E2eCipher _cipher([int fill = 7, bool acceptPlaintext = true]) =>
|
||||
E2eCipher(List<int>.filled(32, fill), acceptPlaintext: acceptPlaintext);
|
||||
|
||||
Hlc _hlc(int ms, int counter, String node) =>
|
||||
Hlc(wallTimeMs: ms, counter: counter, nodeId: node);
|
||||
|
||||
Log _logFrom(List<(int, int, String)> rows) {
|
||||
final log = <String, Record>{};
|
||||
for (final (id, field, value) in rows) {
|
||||
final key = 'r$id';
|
||||
final old = log[key];
|
||||
log[key] = Record(
|
||||
id: key,
|
||||
fields: {
|
||||
...?old?.fields,
|
||||
'f$field': (value, _hlc(1000 + id * 7 + field, field, 'n$id')),
|
||||
},
|
||||
deleted: id % 4 == 0,
|
||||
deletedHlc: id % 4 == 0 ? _hlc(5000, 0, 'n$id') : null,
|
||||
);
|
||||
}
|
||||
return log;
|
||||
}
|
||||
|
||||
final _rows = any.list(
|
||||
any.combine3(
|
||||
any.intInRange(0, 6),
|
||||
any.intInRange(0, 4),
|
||||
any.letterOrDigits,
|
||||
(a, b, c) => (a, b, c),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
Glados(_rows, ExploreConfig(numRuns: 40)).test(
|
||||
'decode(open(seal(encode(log)))) == log',
|
||||
(rows) async {
|
||||
final log = _logFrom(rows);
|
||||
final cipher = _cipher();
|
||||
final sealed = await sealLogText(encodeLog(log), cipher, path: _path);
|
||||
final opened = await openLogText(sealed, cipher, path: _path);
|
||||
expect(encodeLog(decodeLog(opened)), encodeLog(log));
|
||||
},
|
||||
);
|
||||
|
||||
Glados2(_rows, _rows, ExploreConfig(numRuns: 40)).test(
|
||||
'merge through the envelope == merge without it',
|
||||
(a, b) async {
|
||||
final cipher = _cipher();
|
||||
Future<Log> viaCipher(Log log) async => decodeLog(
|
||||
await openLogText(
|
||||
await sealLogText(encodeLog(log), cipher, path: _path),
|
||||
cipher,
|
||||
path: _path,
|
||||
),
|
||||
);
|
||||
final la = _logFrom(a);
|
||||
final lb = _logFrom(b);
|
||||
final plain = mergeLogs(la, lb);
|
||||
final sealed = mergeLogs(await viaCipher(la), await viaCipher(lb));
|
||||
expect(encodeLog(sealed), encodeLog(plain));
|
||||
},
|
||||
);
|
||||
|
||||
test('ids and HLCs are clear, names and values are not', () async {
|
||||
final log = logOf({'n1': 'buy milk'});
|
||||
final sealed = await sealLogText(encodeLog(log), _cipher(), path: _path);
|
||||
final json = jsonDecode(sealed) as Map<String, dynamic>;
|
||||
expect(json['v'], 1);
|
||||
final rec = (json['r'] as Map<String, dynamic>)['n1'] as Map;
|
||||
expect(rec['h'], log['n1']!.fields['title']!.$2.toStr());
|
||||
expect(rec['d'], false);
|
||||
expect(sealed, isNot(contains('title')));
|
||||
expect(sealed, isNot(contains('buy milk')));
|
||||
expect(isE2eEnvelope(sealed), isTrue);
|
||||
expect(isE2eEnvelope(encodeLog(log)), isFalse);
|
||||
});
|
||||
|
||||
test('legacy plaintext passes only while acceptPlaintext', () async {
|
||||
final text = encodeLog(logOf({'n1': 'x'}));
|
||||
expect(await openLogText(text, _cipher(), path: _path), text);
|
||||
expect(
|
||||
() => openLogText(text, _cipher(7, false), path: _path),
|
||||
throwsA(isA<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('a record id named v is still legacy plaintext', () async {
|
||||
final text = encodeLog(logOf({'v': 'x'}));
|
||||
expect(isE2eEnvelope(text), isFalse);
|
||||
});
|
||||
|
||||
group('tamper and wrong key are E2eDecryptError', () {
|
||||
Future<String> sealed() async =>
|
||||
sealLogText(encodeLog(logOf({'n1': 'x'})), _cipher(), path: _path);
|
||||
|
||||
test('wrong key', () async {
|
||||
final text = await sealed();
|
||||
expect(
|
||||
() => openLogText(text, _cipher(8), path: _path),
|
||||
throwsA(isA<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
test('moved path', () async {
|
||||
final text = await sealed();
|
||||
expect(
|
||||
() => openLogText(text, _cipher(), path: '$_path-x'),
|
||||
throwsA(isA<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
test('flipped clear deleted flag', () async {
|
||||
final json = jsonDecode(await sealed()) as Map<String, dynamic>;
|
||||
((json['r'] as Map)['n1'] as Map)['d'] = true;
|
||||
expect(
|
||||
() => openLogText(jsonEncode(json), _cipher(), path: _path),
|
||||
throwsA(isA<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
test('record swapped to another id', () async {
|
||||
final json = jsonDecode(await sealed()) as Map<String, dynamic>;
|
||||
final r = json['r'] as Map;
|
||||
r['n2'] = r.remove('n1');
|
||||
expect(
|
||||
() => openLogText(jsonEncode(json), _cipher(), path: _path),
|
||||
throwsA(isA<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
test('mis-shaped envelope', () async {
|
||||
expect(
|
||||
() => openLogText('{"v":1,"r":5}', _cipher(), path: _path),
|
||||
throwsA(isA<E2eDecryptError>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('text that is not a log is sealed whole and opens back', () async {
|
||||
final cipher = _cipher();
|
||||
final sealed = await sealLogText('{"n":{"id":1}}', cipher, path: _path);
|
||||
expect(sealed, isNot(contains('"id"')));
|
||||
expect(await openLogText(sealed, cipher, path: _path), '{"n":{"id":1}}');
|
||||
});
|
||||
}
|
||||
110
crdt_sync_dart/test/e2e_fakes.dart
Normal file
110
crdt_sync_dart/test/e2e_fakes.dart
Normal file
@ -0,0 +1,110 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
|
||||
const cheapArgon = Argon2Params(memoryKiB: 8, iterations: 1, parallelism: 1);
|
||||
|
||||
Log logOf(Map<String, String> titles, {String nodeId = 'node-a'}) => {
|
||||
for (final entry in titles.entries)
|
||||
entry.key: Record(
|
||||
id: entry.key,
|
||||
fields: {
|
||||
'title': (
|
||||
entry.value,
|
||||
Hlc(wallTimeMs: 1000, counter: 0, nodeId: nodeId),
|
||||
),
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
String encodeLog(Log log) =>
|
||||
jsonEncode(log.map((id, record) => MapEntry(id, record.toJson())));
|
||||
|
||||
Log decodeLog(String text) => (jsonDecode(text) as Map<String, dynamic>).map(
|
||||
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
|
||||
);
|
||||
|
||||
/// In-memory remote; [bulk] toggles the [BulkMapReader] capability.
|
||||
class MemoryRemote implements RemoteStore, BulkMapReader {
|
||||
MemoryRemote([Map<String, String>? files]) : files = files ?? {};
|
||||
|
||||
final Map<String, String> files;
|
||||
final List<String> writes = [];
|
||||
final List<String> reads = [];
|
||||
bool closed = false;
|
||||
|
||||
/// Overrides the next reads of a path, oldest first, to fake a race.
|
||||
final Map<String, List<String?>> scriptedReads = {};
|
||||
|
||||
@override
|
||||
Future<List<String>> listDirectory(String path) async => files.keys
|
||||
.where((key) => key.startsWith('$path/'))
|
||||
.map((key) => key.substring(path.length + 1).split('/').first)
|
||||
.toSet()
|
||||
.toList();
|
||||
|
||||
@override
|
||||
Future<String?> getFileText(String path) async {
|
||||
reads.add(path);
|
||||
final script = scriptedReads[path];
|
||||
if (script != null && script.isNotEmpty) return script.removeAt(0);
|
||||
return files[path];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> putFileText(
|
||||
String path,
|
||||
String text, {
|
||||
required String message,
|
||||
}) async {
|
||||
writes.add(path);
|
||||
files[path] = text;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> getStringMap(String path) async => {
|
||||
for (final entry in files.entries)
|
||||
if (entry.key.startsWith('$path/'))
|
||||
entry.key.substring(path.length + 1): entry.value,
|
||||
};
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String path, {String message = ''}) async =>
|
||||
files.remove(path);
|
||||
|
||||
@override
|
||||
Future<bool> canAccessRemote() async => true;
|
||||
|
||||
@override
|
||||
void close() => closed = true;
|
||||
}
|
||||
|
||||
/// Same storage without the bulk capability, like `GitHubClient`.
|
||||
class PlainRemote implements RemoteStore {
|
||||
PlainRemote(this.inner);
|
||||
|
||||
final MemoryRemote inner;
|
||||
|
||||
@override
|
||||
Future<List<String>> listDirectory(String path) => inner.listDirectory(path);
|
||||
|
||||
@override
|
||||
Future<String?> getFileText(String path) => inner.getFileText(path);
|
||||
|
||||
@override
|
||||
Future<void> putFileText(
|
||||
String path,
|
||||
String text, {
|
||||
required String message,
|
||||
}) => inner.putFileText(path, text, message: message);
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String path, {String message = ''}) =>
|
||||
inner.deleteFile(path);
|
||||
|
||||
@override
|
||||
Future<bool> canAccessRemote() => inner.canAccessRemote();
|
||||
|
||||
@override
|
||||
void close() => inner.close();
|
||||
}
|
||||
181
crdt_sync_dart/test/e2e_key_test.dart
Normal file
181
crdt_sync_dart/test/e2e_key_test.dart
Normal file
@ -0,0 +1,181 @@
|
||||
/// Passphrase -> key, the clear header beside the data, and the open gate.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'e2e_fakes.dart';
|
||||
|
||||
const _prefix = 'todo-sync/notes';
|
||||
const _headerPath = 'todo-sync/e2e';
|
||||
|
||||
Future<E2eKey> _unlock(MemoryRemote remote, String passphrase) => unlockE2e(
|
||||
store: remote,
|
||||
pathPrefix: _prefix,
|
||||
passphrase: passphrase,
|
||||
params: cheapArgon,
|
||||
random: Random(1),
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('the header sits beside the device dirs, not among them', () {
|
||||
expect(defaultE2ePath(_prefix), _headerPath);
|
||||
expect(defaultE2ePath('wake-alarm'), 'wake-alarm/e2e');
|
||||
});
|
||||
|
||||
test(
|
||||
'first unlock writes a clear header with salt, params and check',
|
||||
() async {
|
||||
final remote = MemoryRemote();
|
||||
await _unlock(remote, 'correct horse');
|
||||
final header = jsonDecode(remote.files[_headerPath]!) as Map;
|
||||
expect(header['v'], 1);
|
||||
expect(header['kdf'], 'argon2id');
|
||||
expect(base64.decode(header['salt'] as String), hasLength(16));
|
||||
expect(header['m'], 8);
|
||||
expect(header['t'], 1);
|
||||
expect(header['p'], 1);
|
||||
expect(header['check'], isA<String>());
|
||||
expect(remote.files[_headerPath], isNot(contains('correct horse')));
|
||||
},
|
||||
);
|
||||
|
||||
test('the same passphrase on another device yields the same key', () async {
|
||||
final remote = MemoryRemote();
|
||||
final a = await _unlock(remote, 'pw');
|
||||
final b = await unlockE2e(
|
||||
store: remote,
|
||||
pathPrefix: _prefix,
|
||||
passphrase: 'pw',
|
||||
random: Random(99),
|
||||
);
|
||||
expect(b.bytes, a.bytes);
|
||||
expect(remote.writes, [_headerPath]);
|
||||
});
|
||||
|
||||
test('a wrong passphrase is refused before anything is written', () async {
|
||||
final remote = MemoryRemote();
|
||||
await _unlock(remote, 'pw');
|
||||
remote.writes.clear();
|
||||
await expectLater(
|
||||
_unlock(remote, 'not pw'),
|
||||
throwsA(isA<E2eWrongPassphraseError>()),
|
||||
);
|
||||
expect(remote.writes, isEmpty);
|
||||
});
|
||||
|
||||
test('a setup race derives from the header that won', () async {
|
||||
final winner = MemoryRemote();
|
||||
await unlockE2e(
|
||||
store: winner,
|
||||
pathPrefix: _prefix,
|
||||
passphrase: 'pw',
|
||||
params: cheapArgon,
|
||||
random: Random(5),
|
||||
);
|
||||
final remote = MemoryRemote()
|
||||
..scriptedReads[_headerPath] = [null, winner.files[_headerPath]];
|
||||
final key = await _unlock(remote, 'pw');
|
||||
final expected = await _unlock(winner, 'pw');
|
||||
expect(key.bytes, expected.bytes);
|
||||
});
|
||||
|
||||
test('an empty passphrase is refused', () {
|
||||
expect(() => _unlock(MemoryRemote(), ''), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('a corrupt or newer header is refused, never overwritten', () async {
|
||||
for (final bad in ['nope', '[]', '{"v":2}', '{"v":1,"kdf":"scrypt"}']) {
|
||||
final remote = MemoryRemote({_headerPath: bad});
|
||||
await expectLater(
|
||||
_unlock(remote, 'pw'),
|
||||
throwsA(isA<E2eHeaderError>()),
|
||||
reason: bad,
|
||||
);
|
||||
expect(remote.files[_headerPath], bad);
|
||||
}
|
||||
});
|
||||
|
||||
test('E2eKey.fromBytes round-trips a cached key', () async {
|
||||
final key = await _unlock(MemoryRemote(), 'pw');
|
||||
final cached = await E2eKey.fromBytes(
|
||||
base64.decode(base64.encode(key.bytes)),
|
||||
);
|
||||
expect(cached.bytes, key.bytes);
|
||||
expect(await cached.revision('x'), await key.revision('x'));
|
||||
expect(() => E2eKey.fromBytes(const [1]), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('revision is keyed: not sha256, and differs across keys', () async {
|
||||
final a = await _unlock(MemoryRemote(), 'a');
|
||||
final b = await _unlock(MemoryRemote(), 'b');
|
||||
expect(await a.revision('{}'), isNot(revisionOf('{}')));
|
||||
expect(await a.revision('{}'), isNot(await b.revision('{}')));
|
||||
expect(await a.revision('{}'), await a.revision('{}'));
|
||||
});
|
||||
|
||||
test('errors read as themselves', () {
|
||||
expect(E2eWrongPassphraseError().toString(), contains('passphrase'));
|
||||
expect(E2eHeaderError('x').toString(), 'E2eHeaderError: x');
|
||||
});
|
||||
|
||||
group('openE2e', () {
|
||||
test('no header and no key: off, plaintext sync allowed', () async {
|
||||
final state = await openE2e(store: MemoryRemote(), pathPrefix: _prefix);
|
||||
expect(state, isA<E2eOff>());
|
||||
});
|
||||
|
||||
test('header but no key: locked, needs the passphrase', () async {
|
||||
final remote = MemoryRemote();
|
||||
await _unlock(remote, 'pw');
|
||||
final state = await openE2e(store: remote, pathPrefix: _prefix);
|
||||
expect(state, isA<E2eLocked>());
|
||||
expect((state as E2eLocked).reason, E2eLockReason.needsPassphrase);
|
||||
});
|
||||
|
||||
test('a cached key the header rejects: locked, wrong key', () async {
|
||||
final remote = MemoryRemote();
|
||||
await _unlock(remote, 'pw');
|
||||
final stale = await _unlock(MemoryRemote(), 'old');
|
||||
final state = await openE2e(
|
||||
store: remote,
|
||||
pathPrefix: _prefix,
|
||||
key: stale,
|
||||
);
|
||||
expect((state as E2eLocked).reason, E2eLockReason.wrongKey);
|
||||
});
|
||||
|
||||
test('a cached key with the header gone: locked, wrong key', () async {
|
||||
final key = await _unlock(MemoryRemote(), 'pw');
|
||||
final state = await openE2e(
|
||||
store: MemoryRemote(),
|
||||
pathPrefix: _prefix,
|
||||
key: key,
|
||||
);
|
||||
expect((state as E2eLocked).reason, E2eLockReason.wrongKey);
|
||||
});
|
||||
|
||||
test('a corrupt header: locked, unreadable header', () async {
|
||||
final state = await openE2e(
|
||||
store: MemoryRemote({_headerPath: 'garbage'}),
|
||||
pathPrefix: _prefix,
|
||||
);
|
||||
expect((state as E2eLocked).reason, E2eLockReason.badHeader);
|
||||
});
|
||||
|
||||
test('a matching key: ready with an encrypting store', () async {
|
||||
final remote = MemoryRemote();
|
||||
final key = await _unlock(remote, 'pw');
|
||||
final state = await openE2e(store: remote, pathPrefix: _prefix, key: key);
|
||||
expect((state as E2eReady).store, isA<EncryptingStore>());
|
||||
});
|
||||
});
|
||||
|
||||
test('recommended params meet the OWASP Argon2id floor', () {
|
||||
expect(Argon2Params.recommended.memoryKiB, greaterThanOrEqualTo(19456));
|
||||
expect(Argon2Params.recommended.iterations, greaterThanOrEqualTo(2));
|
||||
});
|
||||
}
|
||||
167
crdt_sync_dart/test/e2e_migration_test.dart
Normal file
167
crdt_sync_dart/test/e2e_migration_test.dart
Normal file
@ -0,0 +1,167 @@
|
||||
/// One-time copy from the plaintext prefix to `<prefix>-e2e`.
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'e2e_fakes.dart';
|
||||
|
||||
const _old = 'app/notes';
|
||||
|
||||
Future<E2eKey> _key(MemoryRemote remote, E2eLayout layout) => unlockE2e(
|
||||
store: remote,
|
||||
pathPrefix: layout.prefix,
|
||||
passphrase: 'pw',
|
||||
params: cheapArgon,
|
||||
e2ePath: layout.e2ePath,
|
||||
);
|
||||
|
||||
MemoryRemote _plainRemote() => MemoryRemote({
|
||||
'$_old/phone/log.json': encodeLog(logOf({'a': 'milk', 'b': 'eggs'})),
|
||||
'$_old/pc/log.json': encodeLog(logOf({'c': 'tea'}, nodeId: 'node-b')),
|
||||
});
|
||||
|
||||
void main() {
|
||||
final layout = E2eLayout.of(_old);
|
||||
|
||||
test('layout puts ciphertext on a new prefix, revs apart from old', () {
|
||||
expect(layout.prefix, 'app/notes-e2e');
|
||||
expect(layout.revsPath, isNot(defaultRevsPath(_old)));
|
||||
expect(layout.e2ePath, startsWith('app/'));
|
||||
});
|
||||
|
||||
test('copy seals every device log under the new prefix', () async {
|
||||
final remote = _plainRemote();
|
||||
final key = await _key(remote, layout);
|
||||
final report = await copyToEncrypted(
|
||||
store: remote,
|
||||
key: key,
|
||||
fromPrefix: _old,
|
||||
layout: layout,
|
||||
);
|
||||
expect(report.copied, 2);
|
||||
final blob = remote.files['app/notes-e2e/phone/log.json']!;
|
||||
expect(blob, isNot(contains('milk')));
|
||||
expect(blob, isNot(contains('"title"')));
|
||||
expect(remote.files['$_old/phone/log.json'], contains('milk'));
|
||||
final enc = EncryptingStore(
|
||||
remote,
|
||||
key,
|
||||
pathPrefix: layout.prefix,
|
||||
revsPath: layout.revsPath,
|
||||
e2ePath: layout.e2ePath,
|
||||
);
|
||||
final merged = await syncLog(
|
||||
client: enc,
|
||||
deviceId: 'tablet',
|
||||
pathPrefix: layout.prefix,
|
||||
localLog: {},
|
||||
encode: encodeLog,
|
||||
decode: decodeLog,
|
||||
);
|
||||
expect(merged.keys, unorderedEquals(['a', 'b', 'c']));
|
||||
});
|
||||
|
||||
test('copying twice adds no duplicate and loses nothing', () async {
|
||||
final remote = _plainRemote();
|
||||
final key = await _key(remote, layout);
|
||||
Future<MigrationReport> run() => copyToEncrypted(
|
||||
store: remote,
|
||||
key: key,
|
||||
fromPrefix: _old,
|
||||
layout: layout,
|
||||
);
|
||||
await run();
|
||||
final first = Map.of(remote.files);
|
||||
remote.writes.clear();
|
||||
final second = await run();
|
||||
expect(second.copied, 0);
|
||||
expect(remote.writes, isEmpty);
|
||||
expect(remote.files, first);
|
||||
});
|
||||
|
||||
test('a later plaintext write by an old build is copied again', () async {
|
||||
final remote = _plainRemote();
|
||||
final key = await _key(remote, layout);
|
||||
await copyToEncrypted(
|
||||
store: remote,
|
||||
key: key,
|
||||
fromPrefix: _old,
|
||||
layout: layout,
|
||||
);
|
||||
remote.files['$_old/pc/log.json'] = encodeLog(
|
||||
logOf({'c': 'tea', 'd': 'jam'}, nodeId: 'node-b'),
|
||||
);
|
||||
final report = await copyToEncrypted(
|
||||
store: remote,
|
||||
key: key,
|
||||
fromPrefix: _old,
|
||||
layout: layout,
|
||||
);
|
||||
expect(report.copied, 1);
|
||||
expect(
|
||||
await verifyCopy(
|
||||
store: remote,
|
||||
key: key,
|
||||
fromPrefix: _old,
|
||||
layout: layout,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('cleanup refuses before the copy is verified, then deletes', () async {
|
||||
final remote = _plainRemote();
|
||||
final key = await _key(remote, layout);
|
||||
expect(
|
||||
await deletePlaintextPrefix(
|
||||
store: remote,
|
||||
key: key,
|
||||
fromPrefix: _old,
|
||||
layout: layout,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(remote.files['$_old/phone/log.json'], isNotNull);
|
||||
await copyToEncrypted(
|
||||
store: remote,
|
||||
key: key,
|
||||
fromPrefix: _old,
|
||||
layout: layout,
|
||||
);
|
||||
expect(
|
||||
await deletePlaintextPrefix(
|
||||
store: remote,
|
||||
key: key,
|
||||
fromPrefix: _old,
|
||||
layout: layout,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(remote.files.keys.where((k) => k.startsWith('$_old/')), isEmpty);
|
||||
expect(remote.files['app/notes-e2e/phone/log.json'], isNotNull);
|
||||
});
|
||||
|
||||
test('a corrupt source blob is skipped and blocks cleanup', () async {
|
||||
final remote = _plainRemote();
|
||||
remote.files['$_old/bad/log.json'] = '{oops';
|
||||
final key = await _key(remote, layout);
|
||||
final report = await copyToEncrypted(
|
||||
store: remote,
|
||||
key: key,
|
||||
fromPrefix: _old,
|
||||
layout: layout,
|
||||
);
|
||||
expect(report.copied, 2);
|
||||
expect(report.skipped, ['bad/log.json']);
|
||||
expect(
|
||||
await deletePlaintextPrefix(
|
||||
store: remote,
|
||||
key: key,
|
||||
fromPrefix: _old,
|
||||
layout: layout,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
}
|
||||
63
crdt_sync_dart/test/e2e_open_test.dart
Normal file
63
crdt_sync_dart/test/e2e_open_test.dart
Normal file
@ -0,0 +1,63 @@
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'e2e_fakes.dart';
|
||||
|
||||
const _old = 'app/notes';
|
||||
|
||||
Future<E2eState> _open(MemoryRemote r, String? pass) => openEncryptedStore(
|
||||
store: r,
|
||||
oldPrefix: _old,
|
||||
readPassphrase: () async => pass,
|
||||
params: cheapArgon,
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('no passphrase is locked', () async {
|
||||
for (final p in [null, '']) {
|
||||
final s = await _open(MemoryRemote(), p);
|
||||
expect((s as E2eLocked).reason, E2eLockReason.needsPassphrase);
|
||||
}
|
||||
});
|
||||
|
||||
test('right passphrase copies and is ready; wrong one is locked', () async {
|
||||
final r = MemoryRemote({
|
||||
'$_old/phone/log.json': encodeLog(logOf({'a': 'milk'})),
|
||||
});
|
||||
expect(await _open(r, 'pw'), isA<E2eReady>());
|
||||
expect(r.files['app/notes-e2e/phone/log.json'], isNot(contains('milk')));
|
||||
expect(await _open(r, 'pw'), isA<E2eReady>());
|
||||
expect(
|
||||
((await _open(r, 'bad')) as E2eLocked).reason,
|
||||
E2eLockReason.wrongKey,
|
||||
);
|
||||
});
|
||||
|
||||
test('a corrupt header locks as badHeader', () async {
|
||||
final r = MemoryRemote({'app/e2e': 'garbage'});
|
||||
expect(
|
||||
((await _open(r, 'pw')) as E2eLocked).reason,
|
||||
E2eLockReason.badHeader,
|
||||
);
|
||||
});
|
||||
|
||||
test('a failing copy does not stop the open', () async {
|
||||
final r = MemoryRemote();
|
||||
r.files['$_old/phone/log.json'] = encodeLog(logOf({'a': 'x'}));
|
||||
final failing = _FailingList(r);
|
||||
final s = await openEncryptedStore(
|
||||
store: failing,
|
||||
oldPrefix: _old,
|
||||
readPassphrase: () async => 'pw',
|
||||
params: cheapArgon,
|
||||
);
|
||||
expect(s, isA<E2eReady>());
|
||||
});
|
||||
}
|
||||
|
||||
class _FailingList extends PlainRemote {
|
||||
_FailingList(super.inner);
|
||||
@override
|
||||
Future<List<String>> listDirectory(String path) =>
|
||||
throw StateError('list down');
|
||||
}
|
||||
290
crdt_sync_dart/test/encrypting_store_test.dart
Normal file
290
crdt_sync_dart/test/encrypting_store_test.dart
Normal file
@ -0,0 +1,290 @@
|
||||
/// syncLog over an EncryptingStore: the relay sees ciphertext, merge is
|
||||
/// unchanged.
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:glados/glados.dart' hide expect, group, test, setUp;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'e2e_fakes.dart';
|
||||
|
||||
const _prefix = 'todo-sync/notes';
|
||||
const _revs = 'todo-sync/revs';
|
||||
|
||||
Future<E2eKey> _key(MemoryRemote remote, [String passphrase = 'pw']) =>
|
||||
unlockE2e(
|
||||
store: remote,
|
||||
pathPrefix: _prefix,
|
||||
passphrase: passphrase,
|
||||
params: cheapArgon,
|
||||
random: Random(1),
|
||||
);
|
||||
|
||||
Future<Log> _tick(
|
||||
RemoteStore client,
|
||||
String deviceId,
|
||||
Log local, {
|
||||
SyncStateStore? state,
|
||||
List<String>? skipped,
|
||||
}) => syncLog(
|
||||
client: client,
|
||||
deviceId: deviceId,
|
||||
pathPrefix: _prefix,
|
||||
localLog: local,
|
||||
encode: encodeLog,
|
||||
decode: decodeLog,
|
||||
stateStore: state,
|
||||
onPeerSkipped: (id, _) => skipped?.add(id),
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('pushed blob is ciphertext and a peer merges it', () async {
|
||||
final remote = MemoryRemote();
|
||||
final key = await _key(remote);
|
||||
final store = EncryptingStore(remote, key, pathPrefix: _prefix);
|
||||
await _tick(store, 'phone', logOf({'n1': 'buy milk'}));
|
||||
|
||||
final blob = remote.files['$_prefix/phone/log.json']!;
|
||||
expect(blob, isNot(contains('title')));
|
||||
expect(blob, isNot(contains('buy milk')));
|
||||
|
||||
final merged = await _tick(store, 'pc', logOf({'n2': 'call mum'}));
|
||||
expect(merged.keys, unorderedEquals(['n1', 'n2']));
|
||||
expect(remote.files.values.where((v) => v.contains('"title"')), isEmpty);
|
||||
});
|
||||
|
||||
test('published revisions are keyed, not sha256 of the plaintext', () async {
|
||||
final remote = MemoryRemote();
|
||||
final store = EncryptingStore(
|
||||
remote,
|
||||
await _key(remote),
|
||||
pathPrefix: _prefix,
|
||||
);
|
||||
final local = logOf({'n1': 'x'});
|
||||
await _tick(store, 'phone', local, state: InMemorySyncStateStore());
|
||||
final rev = remote.files['$_revs/phone']!;
|
||||
expect(rev, isNot(revisionOf(encodeLog(local))));
|
||||
expect(rev, hasLength(64));
|
||||
});
|
||||
|
||||
test('a peer that has not upgraded is still read', () async {
|
||||
final remote = MemoryRemote();
|
||||
await _tick(remote, 'pc', logOf({'old': 'plain'}));
|
||||
final store = EncryptingStore(
|
||||
remote,
|
||||
await _key(remote),
|
||||
pathPrefix: _prefix,
|
||||
);
|
||||
final merged = await _tick(store, 'phone', logOf({'n1': 'x'}));
|
||||
expect(merged.keys, unorderedEquals(['old', 'n1']));
|
||||
});
|
||||
|
||||
test('a tampered peer is skipped and the others still merge', () async {
|
||||
final remote = MemoryRemote();
|
||||
final store = EncryptingStore(
|
||||
remote,
|
||||
await _key(remote),
|
||||
pathPrefix: _prefix,
|
||||
);
|
||||
await _tick(store, 'b', logOf({'b1': 'y'}));
|
||||
await _tick(store, 'a', logOf({'a1': 'x'}));
|
||||
final blob = remote.files['$_prefix/a/log.json']!;
|
||||
remote.files['$_prefix/a/log.json'] = blob.replaceRange(
|
||||
40,
|
||||
41,
|
||||
blob[40] == 'A' ? 'B' : 'A',
|
||||
);
|
||||
final skipped = <String>[];
|
||||
final merged = await _tick(store, 'c', {}, skipped: skipped);
|
||||
expect(merged.keys, ['b1']);
|
||||
expect(skipped, ['a']);
|
||||
});
|
||||
|
||||
test('unparsable and mis-shaped peers are reported as skipped', () async {
|
||||
final remote = MemoryRemote({
|
||||
'$_prefix/bad/log.json': '{oops',
|
||||
'$_prefix/shape/log.json': '{"n":{"id":1}}',
|
||||
});
|
||||
final skipped = <String>[];
|
||||
await _tick(remote, 'me', {}, skipped: skipped);
|
||||
expect(skipped, unorderedEquals(['bad', 'shape']));
|
||||
});
|
||||
|
||||
test('a peer under another passphrase is skipped, not fatal', () async {
|
||||
final remote = MemoryRemote();
|
||||
final other = MemoryRemote();
|
||||
final mine = EncryptingStore(
|
||||
remote,
|
||||
await _key(remote),
|
||||
pathPrefix: _prefix,
|
||||
);
|
||||
final theirs = EncryptingStore(
|
||||
remote,
|
||||
await _key(other, 'other'),
|
||||
pathPrefix: _prefix,
|
||||
);
|
||||
await _tick(theirs, 'x', logOf({'x1': 'x'}));
|
||||
expect(await _tick(mine, 'me', {}), isEmpty);
|
||||
});
|
||||
|
||||
test('a prefix without a slash keeps revs out of the sealed set', () async {
|
||||
final remote = MemoryRemote();
|
||||
final key = await unlockE2e(
|
||||
store: remote,
|
||||
pathPrefix: 'wake-alarm',
|
||||
passphrase: 'pw',
|
||||
params: cheapArgon,
|
||||
);
|
||||
final store = EncryptingStore(remote, key, pathPrefix: 'wake-alarm');
|
||||
await syncLog(
|
||||
client: store,
|
||||
deviceId: 'phone',
|
||||
pathPrefix: 'wake-alarm',
|
||||
localLog: logOf({'n': 'x'}),
|
||||
encode: encodeLog,
|
||||
decode: decodeLog,
|
||||
stateStore: InMemorySyncStateStore(),
|
||||
);
|
||||
expect(remote.files['wake-alarm/revs/phone'], hasLength(64));
|
||||
expect(remote.files['wake-alarm/phone/log.json'], isNot(contains('"x"')));
|
||||
expect(remote.files['wake-alarm/e2e'], contains('argon2id'));
|
||||
});
|
||||
|
||||
test('paths outside the prefix pass through untouched', () async {
|
||||
final remote = MemoryRemote();
|
||||
final store = EncryptingStore(
|
||||
remote,
|
||||
await _key(remote),
|
||||
pathPrefix: _prefix,
|
||||
);
|
||||
await store.putFileText('other/x', '{"a":1}', message: 'm');
|
||||
expect(remote.files['other/x'], '{"a":1}');
|
||||
expect(await store.getFileText('other/x'), '{"a":1}');
|
||||
expect(await store.getFileText('$_prefix/none/log.json'), isNull);
|
||||
});
|
||||
|
||||
test('a flat <prefix>/<node>.json layout (todo) is sealed too', () async {
|
||||
final remote = MemoryRemote();
|
||||
final store = EncryptingStore(
|
||||
remote,
|
||||
await _key(remote),
|
||||
pathPrefix: _prefix,
|
||||
);
|
||||
final log = encodeLog(logOf({'n1': 'buy milk'}));
|
||||
await store.putFileText('$_prefix/node-a.json', log, message: 'm');
|
||||
expect(remote.files['$_prefix/node-a.json'], isNot(contains('milk')));
|
||||
expect(isE2eEnvelope(remote.files['$_prefix/node-a.json']!), isTrue);
|
||||
expect(await store.getFileText('$_prefix/node-a.json'), log);
|
||||
});
|
||||
|
||||
test('a slashless prefix keeps the header and revs out of sealing', () async {
|
||||
final remote = MemoryRemote();
|
||||
final key = await unlockE2e(
|
||||
store: remote,
|
||||
pathPrefix: 'wake-alarm',
|
||||
passphrase: 'pw',
|
||||
params: cheapArgon,
|
||||
);
|
||||
final store = EncryptingStore(remote, key, pathPrefix: 'wake-alarm');
|
||||
final header = remote.files['wake-alarm/e2e']!;
|
||||
expect(await store.getFileText('wake-alarm/e2e'), header);
|
||||
await store.putFileText('wake-alarm/e2e', header, message: 'm');
|
||||
expect(remote.files['wake-alarm/e2e'], header);
|
||||
});
|
||||
|
||||
test('plaintext is refused when acceptPlaintext is off', () async {
|
||||
final remote = MemoryRemote({
|
||||
'$_prefix/pc/log.json': encodeLog(logOf({'a': 'b'})),
|
||||
});
|
||||
final store = EncryptingStore(
|
||||
remote,
|
||||
await _key(remote),
|
||||
pathPrefix: _prefix,
|
||||
acceptPlaintext: false,
|
||||
);
|
||||
expect(await _tick(store, 'phone', {}), isEmpty);
|
||||
});
|
||||
|
||||
group('delegation', () {
|
||||
test('bulk reads forward when the inner store has them', () async {
|
||||
final remote = MemoryRemote({'$_revs/a': 'r'});
|
||||
final store = EncryptingStore(
|
||||
remote,
|
||||
await _key(remote),
|
||||
pathPrefix: _prefix,
|
||||
);
|
||||
expect(await store.getStringMap(_revs), {'a': 'r'});
|
||||
});
|
||||
|
||||
test('bulk reads degrade to "fetch everything" without them', () async {
|
||||
final remote = MemoryRemote({'$_revs/a': 'r'});
|
||||
final store = EncryptingStore(
|
||||
PlainRemote(remote),
|
||||
await _key(remote),
|
||||
pathPrefix: _prefix,
|
||||
);
|
||||
expect(await store.getStringMap(_revs), isEmpty);
|
||||
});
|
||||
|
||||
test('list, delete, access and close reach the inner store', () async {
|
||||
final remote = MemoryRemote({'$_prefix/a/log.json': '{}'});
|
||||
final store = EncryptingStore(
|
||||
remote,
|
||||
await _key(remote),
|
||||
pathPrefix: _prefix,
|
||||
);
|
||||
expect(await store.listDirectory(_prefix), ['a']);
|
||||
await store.deleteFile('$_prefix/a/log.json', message: 'm');
|
||||
expect(remote.files.containsKey('$_prefix/a/log.json'), isFalse);
|
||||
expect(await store.canAccessRemote(), isTrue);
|
||||
store.close();
|
||||
expect(remote.closed, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// Differential: the same sync rounds over a plain and an encrypting relay
|
||||
// must converge to the same logs.
|
||||
Glados(
|
||||
any.list(
|
||||
any.combine3(
|
||||
any.intInRange(0, 3),
|
||||
any.intInRange(0, 6),
|
||||
any.letterOrDigits,
|
||||
(int d, int n, String t) => (d, n, t),
|
||||
),
|
||||
),
|
||||
ExploreConfig(numRuns: 25),
|
||||
).test('merge is identical with and without encryption', (edits) async {
|
||||
const devices = ['a', 'b', 'c'];
|
||||
Log localFor(int d) => {
|
||||
for (final (i, (dev, note, title)) in edits.indexed)
|
||||
if (dev == d)
|
||||
'n$note': Record(
|
||||
id: 'n$note',
|
||||
fields: {
|
||||
'title': (
|
||||
title,
|
||||
Hlc(wallTimeMs: 1000 + i, counter: 0, nodeId: devices[d]),
|
||||
),
|
||||
},
|
||||
),
|
||||
};
|
||||
final plain = MemoryRemote();
|
||||
final sealedRemote = MemoryRemote();
|
||||
final sealed = EncryptingStore(
|
||||
sealedRemote,
|
||||
await E2eKey.fromBytes(List<int>.filled(32, 3)),
|
||||
pathPrefix: _prefix,
|
||||
);
|
||||
final plainLogs = [for (var d = 0; d < 3; d++) localFor(d)];
|
||||
final sealedLogs = [for (var d = 0; d < 3; d++) localFor(d)];
|
||||
for (var round = 0; round < 2; round++) {
|
||||
for (var d = 0; d < 3; d++) {
|
||||
plainLogs[d] = await _tick(plain, devices[d], plainLogs[d]);
|
||||
sealedLogs[d] = await _tick(sealed, devices[d], sealedLogs[d]);
|
||||
}
|
||||
}
|
||||
expect(sealedLogs, plainLogs);
|
||||
expect(sealedLogs[0], sealedLogs[2]);
|
||||
});
|
||||
}
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
47
crdt_sync_dart/tool/e2e_audit.dart
Normal file
47
crdt_sync_dart/tool/e2e_audit.dart
Normal file
@ -0,0 +1,47 @@
|
||||
/// Read-only done check: lists plaintext blobs under an RTDB root.
|
||||
///
|
||||
/// Usage: `dart run tool/e2e_audit.dart <root> <firebase_auth.json>`
|
||||
/// Exit 0 when every blob is sealed, 1 when any is plaintext, 2 on misuse.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
if (args.length != 2) {
|
||||
stderr.writeln('usage: e2e_audit.dart <root> <firebase_auth.json>');
|
||||
exit(2);
|
||||
}
|
||||
final [root, sessionFile] = args;
|
||||
final home = Platform.environment['HOME'];
|
||||
final cfg =
|
||||
jsonDecode(
|
||||
File('$home/.config/crdt-sync/firebase.json').readAsStringSync(),
|
||||
)
|
||||
as Map<String, dynamic>;
|
||||
final session = FirebaseCredentials.fromJson(
|
||||
jsonDecode(File(sessionFile).readAsStringSync()) as Map<String, dynamic>,
|
||||
);
|
||||
final auth = FirebaseTokenProvider(
|
||||
apiKey: cfg['apiKey'] as String,
|
||||
store: InMemoryCredentialStore(session),
|
||||
);
|
||||
final url = Uri.parse(
|
||||
'${cfg['databaseUrl']}/${encodePath(root)}.json'
|
||||
'?auth=${await auth.idToken()}',
|
||||
);
|
||||
final response = await http.get(url);
|
||||
if (response.statusCode != 200) {
|
||||
stderr.writeln('GET $root: HTTP ${response.statusCode}');
|
||||
exit(2);
|
||||
}
|
||||
final plaintext = plaintextBlobPaths(jsonDecode(response.body));
|
||||
stdout.writeln('plaintext blobs under $root: ${plaintext.length}');
|
||||
for (final path in plaintext) {
|
||||
stdout.writeln(' $root/$path');
|
||||
}
|
||||
exit(plaintext.isEmpty ? 0 : 1);
|
||||
}
|
||||
@ -12,15 +12,15 @@ import 'package:crdt_sync/crdt_sync.dart';
|
||||
/// mode 0600 -- never in source, because this repository is public.
|
||||
final _configDir = '${Platform.environment['HOME']}/.config/crdt-sync';
|
||||
|
||||
Log _decode(String t) => (jsonDecode(t) as Map<String, dynamic>)
|
||||
.map((k, v) => MapEntry(k, Record.fromJson(v as Map<String, dynamic>)));
|
||||
String _encode(Log l) =>
|
||||
jsonEncode(l.map((k, v) => MapEntry(k, v.toJson())));
|
||||
Log _decode(String t) => (jsonDecode(t) as Map<String, dynamic>).map(
|
||||
(k, v) => MapEntry(k, Record.fromJson(v as Map<String, dynamic>)),
|
||||
);
|
||||
String _encode(Log l) => jsonEncode(l.map((k, v) => MapEntry(k, v.toJson())));
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
final cfg = jsonDecode(
|
||||
File('$_configDir/firebase.json').readAsStringSync(),
|
||||
) as Map<String, dynamic>;
|
||||
final cfg =
|
||||
jsonDecode(File('$_configDir/firebase.json').readAsStringSync())
|
||||
as Map<String, dynamic>;
|
||||
final auth = FirebaseTokenProvider(
|
||||
apiKey: cfg['apiKey'] as String,
|
||||
store: InMemoryCredentialStore(),
|
||||
|
||||
@ -23,7 +23,9 @@ const _prefix = '_smoketest/devices';
|
||||
var _failures = 0;
|
||||
|
||||
void _check(String what, bool ok, [String detail = '']) {
|
||||
stdout.writeln('${ok ? " ok " : " FAIL"} $what${detail.isEmpty ? '' : ' ($detail)'}');
|
||||
stdout.writeln(
|
||||
'${ok ? " ok " : " FAIL"} $what${detail.isEmpty ? '' : ' ($detail)'}',
|
||||
);
|
||||
if (!ok) _failures++;
|
||||
}
|
||||
|
||||
@ -68,16 +70,24 @@ Future<void> main(List<String> args) async {
|
||||
_check('canAccessRemote is false before sign-in', !await auth.hasSession());
|
||||
await auth.signIn(email: email, password: password);
|
||||
_check('signIn stored a session', await auth.hasSession());
|
||||
_check('canAccessRemote is true after sign-in', await client.canAccessRemote());
|
||||
_check(
|
||||
'canAccessRemote is true after sign-in',
|
||||
await client.canAccessRemote(),
|
||||
);
|
||||
|
||||
stdout.writeln('\n== round-trip and key escaping ==');
|
||||
const path = '$_prefix/pc/log.json';
|
||||
await client.putFileText(path, '{"hello":"world"}', message: 'smoke');
|
||||
final read = await client.getFileText(path);
|
||||
_check('a .json filename round-trips byte-for-byte',
|
||||
read == '{"hello":"world"}', read ?? 'null');
|
||||
_check('missing path reads as null',
|
||||
await client.getFileText('$_prefix/nope/log.json') == null);
|
||||
_check(
|
||||
'a .json filename round-trips byte-for-byte',
|
||||
read == '{"hello":"world"}',
|
||||
read ?? 'null',
|
||||
);
|
||||
_check(
|
||||
'missing path reads as null',
|
||||
await client.getFileText('$_prefix/nope/log.json') == null,
|
||||
);
|
||||
final listed = await client.listDirectory(_prefix);
|
||||
_check('listDirectory sees the device', listed.contains('pc'), '$listed');
|
||||
|
||||
@ -96,8 +106,11 @@ Future<void> main(List<String> args) async {
|
||||
final pushed = await client.getFileText(path);
|
||||
_check('the log landed in the database', pushed != null);
|
||||
final revs = await client.getStringMap('_smoketest/revs');
|
||||
_check('a revision was published for this device',
|
||||
revs['pc'] == revisionOf(_encode(pcLog)), '${revs.keys}');
|
||||
_check(
|
||||
'a revision was published for this device',
|
||||
revs['pc'] == revisionOf(_encode(pcLog)),
|
||||
'${revs.keys}',
|
||||
);
|
||||
|
||||
stdout.writeln('\n== sync: an unchanged tick writes nothing ==');
|
||||
final before = await client.getStringMap('_smoketest/revs');
|
||||
@ -110,8 +123,10 @@ Future<void> main(List<String> args) async {
|
||||
decode: _decode,
|
||||
stateStore: storePc,
|
||||
);
|
||||
_check('revision unchanged, so nothing was re-uploaded',
|
||||
(await client.getStringMap('_smoketest/revs'))['pc'] == before['pc']);
|
||||
_check(
|
||||
'revision unchanged, so nothing was re-uploaded',
|
||||
(await client.getStringMap('_smoketest/revs'))['pc'] == before['pc'],
|
||||
);
|
||||
|
||||
stdout.writeln('\n== sync: two devices converge ==');
|
||||
final storePhone = InMemorySyncStateStore();
|
||||
@ -135,8 +150,10 @@ Future<void> main(List<String> args) async {
|
||||
stateStore: storePc,
|
||||
);
|
||||
_check('pc merged the phone record', pcAfter.containsKey('b'));
|
||||
_check('each device wrote only its own revision key',
|
||||
(await client.getStringMap('_smoketest/revs')).length == 2);
|
||||
_check(
|
||||
'each device wrote only its own revision key',
|
||||
(await client.getStringMap('_smoketest/revs')).length == 2,
|
||||
);
|
||||
|
||||
stdout.writeln('\n== fail-closed ==');
|
||||
await auth.signOut();
|
||||
@ -160,7 +177,9 @@ Future<void> main(List<String> args) async {
|
||||
client.close();
|
||||
auth.close();
|
||||
stdout.writeln(
|
||||
_failures == 0 ? '\nALL LIVE CHECKS PASSED' : '\n$_failures LIVE CHECK(S) FAILED',
|
||||
_failures == 0
|
||||
? '\nALL LIVE CHECKS PASSED'
|
||||
: '\n$_failures LIVE CHECK(S) FAILED',
|
||||
);
|
||||
exit(_failures == 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
@ -14,6 +14,8 @@ library;
|
||||
export 'src/account_store.dart';
|
||||
export 'src/bootstrap.dart';
|
||||
export 'src/device_id.dart';
|
||||
export 'src/e2e_bootstrap.dart';
|
||||
export 'src/e2e_passphrase_button.dart';
|
||||
export 'src/google_any_sign_in.dart';
|
||||
export 'src/google_desktop_sign_in.dart';
|
||||
export 'src/google_pkce.dart';
|
||||
|
||||
@ -108,9 +108,7 @@ Future<FirebaseAccount?> _adoptSeededSession(
|
||||
}) async {
|
||||
final httpClient = client ?? http.Client();
|
||||
try {
|
||||
final response = await httpClient.get(
|
||||
base.resolve(routes.credentialsPath),
|
||||
);
|
||||
final response = await httpClient.get(base.resolve(routes.credentialsPath));
|
||||
if (response.statusCode != 200) return null;
|
||||
final body = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
if (body is! Map<String, dynamic>) return null;
|
||||
|
||||
34
crdt_sync_flutter/lib/src/e2e_bootstrap.dart
Normal file
34
crdt_sync_flutter/lib/src/e2e_bootstrap.dart
Normal file
@ -0,0 +1,34 @@
|
||||
/// End-to-end encryption for an app's sync prefix, keyed by the shared
|
||||
/// crdt-sync passphrase kept in the OS keystore (prompt-free after setup).
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:crdt_sync_flutter/src/keystore.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// Keystore key holding the shared crdt-sync passphrase.
|
||||
const String kE2ePassphraseKey = kE2ePassphraseStorageKey;
|
||||
|
||||
/// Stores the passphrase this device derives its key from.
|
||||
Future<void> saveE2ePassphrase(
|
||||
String passphrase, {
|
||||
FlutterSecureStorage storage = kSecureStorage,
|
||||
}) => storage.write(key: kE2ePassphraseKey, value: passphrase);
|
||||
|
||||
/// Forgets the passphrase; the next open is locked.
|
||||
Future<void> clearE2ePassphrase({
|
||||
FlutterSecureStorage storage = kSecureStorage,
|
||||
}) => storage.delete(key: kE2ePassphraseKey);
|
||||
|
||||
/// [openEncryptedStore] reading the passphrase from the OS keystore.
|
||||
Future<E2eState> openEncryptedStoreFromKeystore({
|
||||
required RemoteStore store,
|
||||
required String oldPrefix,
|
||||
FlutterSecureStorage storage = kSecureStorage,
|
||||
Argon2Params params = Argon2Params.recommended,
|
||||
}) => openEncryptedStore(
|
||||
store: store,
|
||||
oldPrefix: oldPrefix,
|
||||
readPassphrase: () => storage.read(key: kE2ePassphraseKey),
|
||||
params: params,
|
||||
);
|
||||
77
crdt_sync_flutter/lib/src/e2e_passphrase_button.dart
Normal file
77
crdt_sync_flutter/lib/src/e2e_passphrase_button.dart
Normal file
@ -0,0 +1,77 @@
|
||||
/// A text-free control to enter the shared crdt-sync passphrase: a lock icon
|
||||
/// opens a hidden field; the check icon (or Enter) saves it to the keystore.
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync_flutter/src/e2e_bootstrap.dart';
|
||||
import 'package:crdt_sync_flutter/src/keystore.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// Lock icon button; [locked] picks the closed or open lock glyph.
|
||||
class E2ePassphraseButton extends StatelessWidget {
|
||||
/// Creates the button. [onSaved] runs after the passphrase is stored.
|
||||
const E2ePassphraseButton({
|
||||
required this.locked,
|
||||
this.onSaved,
|
||||
this.storage = kSecureStorage,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Whether sync is currently locked (no or wrong passphrase).
|
||||
final bool locked;
|
||||
|
||||
/// Called after a passphrase is saved, e.g. to retry the sync.
|
||||
final VoidCallback? onSaved;
|
||||
|
||||
/// The keystore; only tests pass another.
|
||||
final FlutterSecureStorage storage;
|
||||
|
||||
Future<void> _ask(BuildContext context) async {
|
||||
final entered = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (_) => const _PassphraseDialog(),
|
||||
);
|
||||
if (entered == null || entered.isEmpty) return;
|
||||
await saveE2ePassphrase(entered, storage: storage);
|
||||
onSaved?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => IconButton(
|
||||
icon: Icon(locked ? Icons.lock : Icons.lock_open),
|
||||
onPressed: () => _ask(context),
|
||||
);
|
||||
}
|
||||
|
||||
class _PassphraseDialog extends StatefulWidget {
|
||||
const _PassphraseDialog();
|
||||
|
||||
@override
|
||||
State<_PassphraseDialog> createState() => _PassphraseDialogState();
|
||||
}
|
||||
|
||||
class _PassphraseDialogState extends State<_PassphraseDialog> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AlertDialog(
|
||||
content: TextField(
|
||||
controller: _controller,
|
||||
obscureText: true,
|
||||
autofocus: true,
|
||||
onSubmitted: (value) => Navigator.of(context).pop(value),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.check),
|
||||
onPressed: () => Navigator.of(context).pop(_controller.text),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -61,11 +61,11 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: crdt_sync_dart
|
||||
ref: "crdt_sync_dart-v0.11.3"
|
||||
resolved-ref: a2e7334dfec5131170a084510d70361d53dc40bc
|
||||
ref: "crdt_sync_dart-v0.13.1"
|
||||
resolved-ref: eebeb0b51053f160f163140141aba10a447ba1e8
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.11.3"
|
||||
version: "0.13.1"
|
||||
crypto:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -74,6 +74,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.8"
|
||||
cryptography:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cryptography
|
||||
sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.0"
|
||||
fake_async:
|
||||
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.4.0
|
||||
version: 0.7.0
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
@ -13,7 +13,7 @@ dependencies:
|
||||
crdt_sync:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: crdt_sync_dart-v0.11.3
|
||||
ref: crdt_sync_dart-v0.13.1
|
||||
path: crdt_sync_dart
|
||||
crypto: 3.0.8
|
||||
flutter:
|
||||
|
||||
@ -20,9 +20,7 @@ void main() {
|
||||
group('loadAccount', () {
|
||||
test('returns the stored account without touching the wrapper', () async {
|
||||
const stored = FirebaseAccount(email: 'a@b.c', password: 'pw');
|
||||
installFakeSecureStorage(
|
||||
initial: {kAccountKey: stored.toJsonString()},
|
||||
);
|
||||
installFakeSecureStorage(initial: {kAccountKey: stored.toJsonString()});
|
||||
var requests = 0;
|
||||
final account = await loadAccount(
|
||||
routes: routes,
|
||||
|
||||
138
crdt_sync_flutter/test/e2e_bootstrap_test.dart
Normal file
138
crdt_sync_flutter/test/e2e_bootstrap_test.dart
Normal file
@ -0,0 +1,138 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart';
|
||||
import 'package:crdt_sync_flutter/testing/fake_secure_storage.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
const _old = 'todo-sync/notes';
|
||||
const _cheap = Argon2Params(memoryKiB: 8, iterations: 1, parallelism: 1);
|
||||
|
||||
class _Remote implements RemoteStore {
|
||||
final files = <String, String>{};
|
||||
@override
|
||||
Future<List<String>> listDirectory(String path) async => files.keys
|
||||
.where((k) => k.startsWith('$path/'))
|
||||
.map((k) => k.substring(path.length + 1).split('/').first)
|
||||
.toSet()
|
||||
.toList();
|
||||
@override
|
||||
Future<String?> getFileText(String path) async => files[path];
|
||||
@override
|
||||
Future<void> putFileText(
|
||||
String path,
|
||||
String text, {
|
||||
required String message,
|
||||
}) async => files[path] = text;
|
||||
@override
|
||||
Future<void> deleteFile(String path, {String message = ''}) async =>
|
||||
files.remove(path);
|
||||
@override
|
||||
Future<bool> canAccessRemote() async => true;
|
||||
@override
|
||||
void close() {}
|
||||
}
|
||||
|
||||
String _log(String title) => jsonEncode({
|
||||
'n1': {
|
||||
'id': 'n1',
|
||||
'fields': {
|
||||
'title': [title, '2026-01-01T00:00:00.000Z-0000-a'],
|
||||
},
|
||||
'deleted': false,
|
||||
'deleted_hlc': null,
|
||||
},
|
||||
});
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
const storage = FlutterSecureStorage();
|
||||
|
||||
setUp(installFakeSecureStorage);
|
||||
|
||||
test('no passphrase on the device is locked, never plaintext', () async {
|
||||
final state = await openEncryptedStoreFromKeystore(
|
||||
store: _Remote(),
|
||||
oldPrefix: _old,
|
||||
params: _cheap,
|
||||
);
|
||||
expect(state, isA<E2eLocked>());
|
||||
expect((state as E2eLocked).reason, E2eLockReason.needsPassphrase);
|
||||
});
|
||||
|
||||
test('right passphrase unlocks and copies old plaintext once', () async {
|
||||
final remote = _Remote()..files['$_old/phone/log.json'] = _log('milk');
|
||||
await saveE2ePassphrase('pw', storage: storage);
|
||||
final state = await openEncryptedStoreFromKeystore(
|
||||
store: remote,
|
||||
oldPrefix: _old,
|
||||
params: _cheap,
|
||||
);
|
||||
expect(state, isA<E2eReady>());
|
||||
final layout = E2eLayout.of(_old);
|
||||
expect(
|
||||
remote.files['${layout.prefix}/phone/log.json'],
|
||||
isNot(contains('milk')),
|
||||
);
|
||||
expect(remote.files['$_old/phone/log.json'], contains('milk'));
|
||||
final again = await openEncryptedStoreFromKeystore(
|
||||
store: remote,
|
||||
oldPrefix: _old,
|
||||
params: _cheap,
|
||||
);
|
||||
expect(again, isA<E2eReady>());
|
||||
});
|
||||
|
||||
test('second device with the same passphrase reads the data', () async {
|
||||
final remote = _Remote();
|
||||
await saveE2ePassphrase('pw', storage: storage);
|
||||
final first =
|
||||
await openEncryptedStoreFromKeystore(
|
||||
store: remote,
|
||||
oldPrefix: _old,
|
||||
params: _cheap,
|
||||
)
|
||||
as E2eReady;
|
||||
final layout = E2eLayout.of(_old);
|
||||
await first.store.putFileText(
|
||||
'${layout.prefix}/phone/log.json',
|
||||
_log('tea'),
|
||||
message: 'm',
|
||||
);
|
||||
final second =
|
||||
await openEncryptedStoreFromKeystore(
|
||||
store: remote,
|
||||
oldPrefix: _old,
|
||||
params: _cheap,
|
||||
)
|
||||
as E2eReady;
|
||||
final text = await second.store.getFileText(
|
||||
'${layout.prefix}/phone/log.json',
|
||||
);
|
||||
expect(text, contains('tea'));
|
||||
});
|
||||
|
||||
test('wrong passphrase is locked and merges nothing', () async {
|
||||
final remote = _Remote();
|
||||
await saveE2ePassphrase('pw', storage: storage);
|
||||
await openEncryptedStoreFromKeystore(
|
||||
store: remote,
|
||||
oldPrefix: _old,
|
||||
params: _cheap,
|
||||
);
|
||||
await saveE2ePassphrase('nope', storage: storage);
|
||||
final state = await openEncryptedStoreFromKeystore(
|
||||
store: remote,
|
||||
oldPrefix: _old,
|
||||
params: _cheap,
|
||||
);
|
||||
expect((state as E2eLocked).reason, E2eLockReason.wrongKey);
|
||||
});
|
||||
|
||||
test('clearing the passphrase forgets it', () async {
|
||||
await saveE2ePassphrase('pw', storage: storage);
|
||||
await clearE2ePassphrase(storage: storage);
|
||||
expect(await storage.read(key: kE2ePassphraseKey), isNull);
|
||||
});
|
||||
}
|
||||
65
crdt_sync_flutter/test/e2e_passphrase_button_test.dart
Normal file
65
crdt_sync_flutter/test/e2e_passphrase_button_test.dart
Normal file
@ -0,0 +1,65 @@
|
||||
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart';
|
||||
import 'package:crdt_sync_flutter/testing/fake_secure_storage.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUp(installFakeSecureStorage);
|
||||
|
||||
Future<void> pump(WidgetTester t, {required bool locked, VoidCallback? cb}) =>
|
||||
t.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: E2ePassphraseButton(locked: locked, onSaved: cb),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('icon follows the locked flag', (t) async {
|
||||
await pump(t, locked: true);
|
||||
expect(find.byIcon(Icons.lock), findsOneWidget);
|
||||
await pump(t, locked: false);
|
||||
expect(find.byIcon(Icons.lock_open), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('entering a passphrase stores it and notifies', (t) async {
|
||||
var saved = 0;
|
||||
await pump(t, locked: true, cb: () => saved++);
|
||||
await t.tap(find.byType(IconButton));
|
||||
await t.pumpAndSettle();
|
||||
await t.enterText(find.byType(TextField), 'pw');
|
||||
await t.tap(find.byIcon(Icons.check));
|
||||
await t.pumpAndSettle();
|
||||
expect(
|
||||
await const FlutterSecureStorage().read(key: kE2ePassphraseKey),
|
||||
'pw',
|
||||
);
|
||||
expect(saved, 1);
|
||||
});
|
||||
|
||||
testWidgets('submitting with Enter saves; empty input saves nothing', (
|
||||
t,
|
||||
) async {
|
||||
await pump(t, locked: true);
|
||||
await t.tap(find.byType(IconButton));
|
||||
await t.pumpAndSettle();
|
||||
await t.enterText(find.byType(TextField), 'x1');
|
||||
await t.testTextInput.receiveAction(TextInputAction.done);
|
||||
await t.pumpAndSettle();
|
||||
expect(
|
||||
await const FlutterSecureStorage().read(key: kE2ePassphraseKey),
|
||||
'x1',
|
||||
);
|
||||
await clearE2ePassphrase();
|
||||
await t.tap(find.byType(IconButton));
|
||||
await t.pumpAndSettle();
|
||||
await t.tap(find.byIcon(Icons.check));
|
||||
await t.pumpAndSettle();
|
||||
expect(
|
||||
await const FlutterSecureStorage().read(key: kE2ePassphraseKey),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -11,7 +11,7 @@ dependencies:
|
||||
data_transfer_flutter:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: data_transfer_flutter-v0.2.0
|
||||
ref: data_transfer_flutter-v0.2.1
|
||||
path: data_transfer_flutter
|
||||
```
|
||||
|
||||
|
||||
@ -4,13 +4,13 @@ description: >-
|
||||
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.0
|
||||
version: 0.2.2
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
dependencies:
|
||||
file_picker: 13.3.1
|
||||
file_picker: 13.3.3
|
||||
file_selector: 1.1.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
@ -91,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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -8,9 +8,10 @@ import 'package:flutter/material.dart';
|
||||
///
|
||||
/// 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. [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.
|
||||
/// 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({
|
||||
@ -54,6 +55,10 @@ class FitGrid extends StatefulWidget {
|
||||
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;
|
||||
@ -159,22 +164,35 @@ class _Dots extends StatelessWidget {
|
||||
return ExcludeSemantics(
|
||||
child: SizedBox(
|
||||
height: _dotsHeight,
|
||||
child: 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,
|
||||
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.3.0
|
||||
version: 0.3.1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
@ -126,6 +126,30 @@ void main() {
|
||||
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 {
|
||||
|
||||
@ -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` — deleted (done) |
|
||||
| 05 | `~/src/screen-locker/prompts/dopamine-ux-05-workout-app.md` — deleted (done) |
|
||||
| 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 | DONE 2026-11-01 | `89c65bd` | Status window `_section_streak` + week-transition reward text (kept in `extra_benefits_state.json`); the dead 09:00 early-bird claim is not shown. Per kuhy: no daily-streak state, no shutdown time, no freezes. Commit is on a worktree branch, unpushed. |
|
||||
| 05-workout-app.md | DONE 2026-11-01 | `e938201` | Set/warmup `selectionClick`, finish `mediumImpact` + generated chime (music_theory, pinned tag) + dialog arrival, `AppDuration`/`AppCurve` in theme.dart, streak counts up, reduced motion honoured, one Settings "Sound" switch for chime and break-end (foreground service too). Worktree commits, unpushed; `62c1c2c` covers old export/import gaps. |
|
||||
| 06-todo.md | PARTIAL 2026-11-02 (step 1 DONE; step 2 sound REJECTED by kuhy) | `kuhyx/todo-app 2ffae26` | haptic + checkmark on capture, appRoute motion tokens, animated wizard bar; no sound by decision |
|
||||
| 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,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
|
||||
@ -1,194 +0,0 @@
|
||||
Close the feedback asymmetry in workout_app
|
||||
|
||||
## findings (4, ranked by value over effort)
|
||||
|
||||
1. **Finishing a workout gets no cue** — while the rest timer gets sound *and*
|
||||
an 800ms vibration. The app celebrates waiting and ignores achieving. All
|
||||
infrastructure is already present; only the call is missing.
|
||||
2. **Set completion has no haptic** — the app's most repeated action, silent.
|
||||
3. **The fleet's only animation is an inline magic number** —
|
||||
`Duration(milliseconds: 200)` in `exercise_tile_rows.dart`.
|
||||
4. **No sound opt-out exists** — the SQLite settings table is ready, but no
|
||||
public accessor and no `SwitchListTile` anywhere. Step 2 only.
|
||||
|
||||
## what
|
||||
|
||||
workout_app is the only app in kuhy's fleet that can already play a sound and
|
||||
vibrate — and it spends that capability on the wrong moment. Verified 2026-08-16:
|
||||
|
||||
- **Rest-timer expiry** gets a full cue: `_playBreakEndCue()` in
|
||||
`lib/screens/workout_screen_session.dart` (~:128-145) plays
|
||||
`assets/sounds/break_end.mp3` and fires `Vibration.vibrate(duration: 800)`.
|
||||
- **Finishing an entire workout** gets nothing. `_persistFinishedWorkout()` in
|
||||
`lib/screens/workout_screen_finish.dart` (~:99-108) ends by showing
|
||||
`WorkoutSummaryDialog` with no sound, no haptic, no animation.
|
||||
- **Completing a set** — the core repeated action of the whole app — gets no
|
||||
haptic either. The `GestureDetector` at `lib/widgets/exercise_tile_rows.dart`
|
||||
(~:145) toggles state silently.
|
||||
|
||||
So the app celebrates *waiting* and ignores *achieving*. Fix the asymmetry: the
|
||||
moments worth reinforcing are completing a set and finishing a workout.
|
||||
|
||||
This app also contains the fleet's **only animation** — a lone
|
||||
`AnimatedContainer(duration: Duration(milliseconds: 200))` at
|
||||
`exercise_tile_rows.dart` ~:146-147, with an inline magic number. Replace that
|
||||
number with a motion token while you are in the file.
|
||||
|
||||
## where
|
||||
|
||||
Repo: `~/src/screen-locker`. App: `~/src/screen-locker/stronglift_replacement/workout_app`
|
||||
(a Flutter app nested inside the screen-locker repo; that directory contains **no
|
||||
Python** — only the app and two design docs).
|
||||
|
||||
Primary:
|
||||
- `lib/widgets/exercise_tile_rows.dart` — set-completion `GestureDetector` ~:145,
|
||||
the `AnimatedContainer` ~:146-147.
|
||||
- `lib/screens/workout_screen_finish.dart` — `_persistFinishedWorkout()` ~:99-108.
|
||||
- `lib/widgets/workout_summary_dialog.dart` — `WorkoutSummaryDialog` ~:10.
|
||||
- `lib/screens/workout_screen_session.dart` — `_playBreakEndCue()` ~:128-145, the
|
||||
existing cue to model the new ones on. Note its `.catchError` only
|
||||
`debugPrint`s (~:131-137).
|
||||
- `lib/screens/workout_screen.dart` — `_audio = AudioPlayer()` ~:78, `vibration`
|
||||
import ~:9.
|
||||
- `lib/widgets/rep_circle.dart` — `RepCircle` ~:34, per-set completion indicator.
|
||||
|
||||
Progress/history surfaces (already rich; animate rather than rebuild):
|
||||
- `lib/screens/history_screen_charts.dart` — `_ProgressStatsCard` ~:71,
|
||||
**`_StreakRow` ~:139**, `_WeightChart` ~:192.
|
||||
- `lib/screens/history_screen_painter.dart` — `_ChartPainter` ~:9, `paint()` ~:46,
|
||||
`shouldRepaint` ~:121 (static; no animation today).
|
||||
- Streak data: `successStreak`/`failStreak` on `ExerciseState`
|
||||
(`lib/services/storage_service.dart` ~:42-46).
|
||||
|
||||
Settings (for the sound opt-out):
|
||||
- `lib/screens/settings_screen.dart` — `build()` ~:166, `ListView` ~:174-208.
|
||||
Parts: `settings_screen_rows.dart`, `settings_screen_sections.dart`,
|
||||
`settings_screen_exercise_sections.dart`, `settings_screen_thresholds.dart`,
|
||||
`settings_screen_widget.dart`, `settings_screen_actions.dart`.
|
||||
**No `SwitchListTile` exists** — model a new section on `_SyncSection`
|
||||
(`settings_screen_sections.dart` ~:14) or `_OfflineBackupSection`.
|
||||
- Store: **SQLite**, not SharedPreferences. There is a ready-made key/value table
|
||||
— schema `lib/services/storage_service_schema.dart` ~:34-36
|
||||
(`CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, migration
|
||||
~:64-65), accessors `lib/services/storage_service.dart` ~:122-137
|
||||
(`_getSetting`/`_setSetting`, both **private** — add a public typed wrapper like
|
||||
the existing `setLastWorkoutType`). Already backed up/restored via
|
||||
`lib/services/storage_service_backup.dart` ~:23, :43, :156-163.
|
||||
|
||||
Theme: `lib/ui/theme.dart` — local hand-copy, **no `design_system` dependency**
|
||||
(`buildAppTheme()` ~:12, `AppStatusColors` ~:76, `AppSpacing` ~:111,
|
||||
`AppRadius` ~:133, `AppTextSize`). Wired at `lib/main.dart` ~:115.
|
||||
|
||||
## must
|
||||
|
||||
**Step 1 — motion + haptics (no new dependency).**
|
||||
|
||||
- Add `HapticFeedback.selectionClick()` on **set completion** — the tap at
|
||||
`exercise_tile_rows.dart` ~:145. This is the app's most repeated action; it
|
||||
should feel like flipping a physical switch. Fire on tap, not after the write.
|
||||
- Add a completion cue when a **workout finishes** — haptic plus a visible
|
||||
arrival for `WorkoutSummaryDialog`, rather than it appearing instantly.
|
||||
- Replace the inline `Duration(milliseconds: 200)` at ~:146-147 with the motion
|
||||
token from prompt 01, transcribed into `lib/ui/theme.dart`.
|
||||
- Animate the streak/stat numbers on the history screen to their new values
|
||||
instead of snapping. `_ChartPainter.shouldRepaint` currently returns a static
|
||||
result — leave the chart itself alone unless animating it is cheap.
|
||||
- Honour `MediaQuery.of(context).disableAnimations` — durations collapse to zero;
|
||||
haptics and sound still fire.
|
||||
- `VIBRATE` is already granted (`android/app/src/main/AndroidManifest.xml` ~:10)
|
||||
and `vibration: ^3.1.0` is already a dependency. Prefer `HapticFeedback` from
|
||||
`flutter/services.dart` for short UI taps and reserve the `vibration` package
|
||||
for the long timed buzz it already does well.
|
||||
|
||||
**Step 2 — sound (separate, independently revertable).**
|
||||
|
||||
- Add a finish-workout cue asset alongside `assets/sounds/break_end.mp3`
|
||||
(declared at `pubspec.yaml` ~:52-53 — **this is the fleet's only `assets:`
|
||||
block**, so you are adding a file, not the block).
|
||||
- Sound ships **on**, with an opt-out. Add a public
|
||||
`getSoundEnabled()`/`setSoundEnabled(bool)` on `StorageService` wrapping
|
||||
`_getSetting`/`_setSetting` (store `'true'`/`'false'`), then a settings section
|
||||
with a `SwitchListTile`.
|
||||
- The toggle must silence the **new** cues and the existing break-end cue alike —
|
||||
one switch, not two behaviours.
|
||||
|
||||
**Both steps:**
|
||||
|
||||
- must not: **reward the weight or rep number.** The cue fires on *logging a set*
|
||||
and *finishing a workout*, not on hitting a PB or beating last week. Rewarding
|
||||
the number pushes toward misreporting; rewarding the record keeps the data
|
||||
honest. A deloaded set gets the same confirmation as a PB.
|
||||
- must not: fabricate the streak. `successStreak`/`failStreak` count real
|
||||
outcomes; display them as they are.
|
||||
- must not: add a cue to *deleting* a set or workout.
|
||||
- must not: silently swallow an audio error. The existing `.catchError` →
|
||||
`debugPrint` at ~:131-137 is the local precedent; do not make it quieter.
|
||||
- must not: break the **100% coverage gate** — new public methods on
|
||||
`StorageService` need tests. See the comments at
|
||||
`settings_screen_sections.dart` ~:1-11.
|
||||
- must not: exceed the **250-line cap** on any file.
|
||||
|
||||
- optional: a subtle near-miss cue on the history screen when a streak is one
|
||||
workout short of a milestone — legitimate only because the number is true.
|
||||
|
||||
## done
|
||||
|
||||
1. Completing a set produces an immediate haptic on the phone.
|
||||
2. Finishing a workout produces a haptic and a visible dialog arrival; step 2
|
||||
adds a sound.
|
||||
3. No inline animation duration remains in `exercise_tile_rows.dart`.
|
||||
4. `cd ~/src/screen-locker/stronglift_replacement/workout_app && flutter analyze` clean.
|
||||
5. `flutter test` passes **and coverage stays at 100%**.
|
||||
6. With OS "remove animations" on, the app works and durations are zero.
|
||||
7. Step 2: the settings toggle flips, persists across restart, and silences both
|
||||
the new cues and the existing break-end cue.
|
||||
|
||||
## verify
|
||||
|
||||
**On the phone.**
|
||||
|
||||
```
|
||||
adb devices # confirm 23181JEGR08034
|
||||
cd ~/src/screen-locker/stronglift_replacement/workout_app
|
||||
flutter build apk --release
|
||||
adb install -r build/app/outputs/flutter-apk/app-release.apk
|
||||
```
|
||||
|
||||
Never `flutter install`, never `adb uninstall`, never `pm clear` — those wipe the
|
||||
SQLite workout history. `adb install -r` preserves it.
|
||||
|
||||
Then run a real set on the device: tap a set complete, finish a workout, and
|
||||
report what the phone actually did. Haptics and sound are physical outcomes —
|
||||
state what you felt and heard; do not infer them from the code.
|
||||
|
||||
## read first
|
||||
|
||||
- `lib/screens/workout_screen_session.dart` ~:128-145 — `_playBreakEndCue()`, the
|
||||
working cue. Your new cues should look like it.
|
||||
- `lib/widgets/exercise_tile_rows.dart` — the set-completion tap and the fleet's
|
||||
only `AnimatedContainer`.
|
||||
- `lib/services/storage_service.dart` ~:122-137 — `_getSetting`/`_setSetting`, and
|
||||
`setLastWorkoutType` as the public-wrapper pattern.
|
||||
- `lib/screens/settings_screen_sections.dart` ~:1-11 — the coverage/line-cap
|
||||
comments, and `_SyncSection` as the section pattern.
|
||||
- `~/src/utils/unified-design-system/motion.md` — motion/haptic vocabulary from
|
||||
prompt 01. **Prompt 01 must have run first.**
|
||||
|
||||
## context you would otherwise rediscover
|
||||
|
||||
- This app has **no `design_system` dependency**; `lib/ui/theme.dart` is a
|
||||
hand-transcription of the shared tokens. Motion tokens must be transcribed
|
||||
there, matching the existing convention. Do not add the dependency here.
|
||||
- `shared_preferences` **is** a dependency but is used only for the device id
|
||||
(`lib/services/sync_device_id.dart` ~:37-38). Settings go in SQLite.
|
||||
- Non-motion `Duration`s that are **not** animation and must not be touched:
|
||||
rest tickers (`workout_screen.dart` ~:91, `workout_screen_breaks.dart` ~:20,
|
||||
`workout_screen_session.dart` ~:37 — 1s), settings debounces
|
||||
(`settings_screen.dart` ~:140, :148 — 600ms), `main.dart` ~:56 (20s).
|
||||
- The app syncs to the screen-locker Python side, which treats
|
||||
`screen_locker/workout_log.json` as the single source of truth. **Do not change
|
||||
what counts as a workout** — enforcement depends on it, and it is out of scope.
|
||||
- The repo enforces a 250-line file cap and bans `# noqa`; ruff runs
|
||||
`select = ["ALL"]` on the Python side.
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
@ -1,198 +0,0 @@
|
||||
Give dufs-cloud real latency feedback on its slow operations
|
||||
|
||||
## findings (4, ranked by value over effort)
|
||||
|
||||
1. **No cancel on the unbounded index walk or the ZIP build** — the real
|
||||
usability failure, and worth more than any animation.
|
||||
2. **Slow operations report one static string** — the walk and the zip both know
|
||||
real counts and discard them.
|
||||
3. **No `prefers-reduced-motion` block anywhere in 806 lines of CSS** — a
|
||||
straight accessibility gap.
|
||||
4. **Long-press multi-select has no haptic** — the canonical place for one.
|
||||
|
||||
## what
|
||||
|
||||
dufs-cloud has genuinely slow operations and tells the user almost nothing while
|
||||
they run. Verified 2026-08-16:
|
||||
|
||||
- The **recursive cloud index walk** lists every directory in the cloud,
|
||||
unbounded. Web shows one static string `"Indexing cloud…"`; Flutter shows one
|
||||
indeterminate `CircularProgressIndicator`. **No count, no current path, no
|
||||
cancel** on either side.
|
||||
- The **multi-file ZIP build** downloads every selected file's bytes into memory
|
||||
and then zips them, with `setBusy('Preparing N item(s)…')` — a single static
|
||||
string for the entire operation. Neither `buildSelectionZip` signature even
|
||||
accepts a progress callback.
|
||||
- Web's entire 806-line `index.css` contains **exactly one transition**
|
||||
(`.tile-actions { transition: opacity 0.12s }` ~:539-544), zero `@keyframes`,
|
||||
and **no `prefers-reduced-motion` block at all**.
|
||||
- Flutter side has **zero animations and zero haptics** — notably
|
||||
`browser_screen.dart` (~:95) enters multi-select on **long-press** with no
|
||||
`HapticFeedback.selectionClick()`, the canonical place for one.
|
||||
|
||||
This is the article's micro-animation point in its most defensible form: replace
|
||||
dead latency with honest, informative feedback. Nothing here needs a manufactured
|
||||
reward — the operations are genuinely slow and the progress is genuinely knowable.
|
||||
|
||||
## where
|
||||
|
||||
Repo: `~/src/dufs-cloud`. Web: `web/`. Flutter: `app/` (package `dufs_client`).
|
||||
|
||||
**First step — bump both dependencies** to pick up prompt 01's motion tokens:
|
||||
- `web/package.json`: `"@kuhyx/web-ui": "github:kuhyx/utils#web_ui-v0.3.1&path:/web_ui"`
|
||||
→ bump to **`web_ui-v0.3.2`** (or whatever tag prompt 01 printed).
|
||||
**pnpm only** — npm cannot install a monorepo subdirectory and will lock the repo.
|
||||
- `app/pubspec.yaml`: bump `design_system` to the tag prompt 01 cut.
|
||||
**Prompt 07 must have run first** — it is what adds this dependency at all.
|
||||
|
||||
Slow operations (the highest-value targets):
|
||||
- Index walk — web `web/src/hooks/use-cloud-index.ts` (`walkInto` ~:36-43, root
|
||||
walk ~:56-63, `loading` derived ~:113); Flutter
|
||||
`app/lib/services/cloud_index.dart` (`buildCloudIndex` ~:25, `_walk` ~:31-41),
|
||||
driven by `app/lib/screens/browser_screen.dart` `_ensureIndex()` ~:184-194.
|
||||
- ZIP build — web `web/src/lib/download.ts` (`gatherFiles` ~:24,
|
||||
`buildSelectionZip` ~:37-47, `saveBytes` ~:55; encoder `web/src/lib/zip.ts`
|
||||
`zipStore` ~:32), triggered from `web/src/components/gallery.tsx` ~:307;
|
||||
Flutter `app/lib/services/download_zip.dart` (`gatherFiles` ~:15,
|
||||
`buildSelectionZip` ~:29-37).
|
||||
- Upload — web `gallery.tsx` ~:186; Flutter
|
||||
`app/lib/services/dufs_client.dart` `upload()` ~:73-81 (a plain `_http.put`
|
||||
with the full body — **not** a `StreamedRequest`, so byte progress is not
|
||||
currently observable), called from `browser_screen.dart` ~:339.
|
||||
- Bulk mutations, all single static strings in `gallery.tsx`: move ~:211/:271,
|
||||
delete ~:229/:290 (the bulk delete loops per item and reports only a final
|
||||
failure count), rename ~:243, create dir ~:257.
|
||||
- Directory listing — web `web/src/hooks/use-listing.ts` ~:31-72; Flutter
|
||||
`browser_screen.dart` ~:120/:140/:149/:155.
|
||||
|
||||
Other surfaces:
|
||||
- `web/src/index.css` (806 lines; `.busy` styled ~:87 — static text, no shimmer).
|
||||
- `web/src/components/filter-bar.tsx` ~:75-83 — the search input fires `onFilter`
|
||||
on **every keystroke with no debounce**.
|
||||
- `web/src/components/gallery.tsx` ~:454 `{loading && <p className="muted">Loading…</p>}`,
|
||||
~:355 the `busy` span; `folder-picker.tsx` ~:97.
|
||||
- Flutter spinners: `browser_screen.dart` ~:717, ~:732, `RefreshIndicator` ~:748
|
||||
(the one built-in motion affordance), plus `text_editor_screen.dart` ~:101/:80,
|
||||
`audio_screen.dart` ~:104, `video_screen.dart` ~:169, `pdf_screen.dart` ~:184,
|
||||
`app/lib/widgets/folder_picker.dart` ~:92.
|
||||
- Settings: `app/lib/screens/settings_screen.dart` (93 lines — room for a toggle),
|
||||
store `app/lib/services/settings.dart`.
|
||||
|
||||
## must
|
||||
|
||||
**Step 1 — motion + honest progress (the valuable half).**
|
||||
|
||||
- Make the index walk report **real progress**: directories walked, files found,
|
||||
and ideally the current path. The walk already knows these numbers — it just
|
||||
discards them. Thread a progress callback through `buildCloudIndex`/`walkInto`.
|
||||
- Make the ZIP build report **per-file progress** (`3 of 47`). Both
|
||||
`buildSelectionZip` signatures need a progress callback added; today neither
|
||||
accepts one.
|
||||
- Add a **cancel** to both. A long unbounded operation with no way out is the
|
||||
actual usability failure here, and it is more valuable than any animation.
|
||||
- Add `HapticFeedback.selectionClick()` on the Flutter **long-press to
|
||||
multi-select** (`browser_screen.dart` ~:95). No dependency needed
|
||||
(`flutter/services.dart`); add `<uses-permission
|
||||
android:name="android.permission.VIBRATE"/>` to the manifest if absent.
|
||||
- Add a `@media (prefers-reduced-motion: reduce)` block to `web/src/index.css`.
|
||||
**There is none today** — this is a straight accessibility gap, not a nicety.
|
||||
Flutter side: honour `MediaQuery.of(context).disableAnimations`.
|
||||
- Use the shared motion tokens for any transition you add. Replace the inline
|
||||
`0.12s` at `index.css` ~:539-544 with the token while you are there.
|
||||
- Debounce the search input (`filter-bar.tsx` ~:75-83). Firing on every keystroke
|
||||
over a large index is the cause of jank, and no animation fixes that.
|
||||
|
||||
**Step 2 — sound (separate, independently revertable).**
|
||||
|
||||
- A completion cue for long operations only (index built, ZIP ready, upload
|
||||
finished) — **not** for navigation or selection. A file manager that chirps on
|
||||
every click is intolerable.
|
||||
- Sound ships **on**, with an opt-out toggle in
|
||||
`app/lib/screens/settings_screen.dart` backed by `app/lib/services/settings.dart`,
|
||||
and an equivalent on web.
|
||||
- Consider suppressing the cue when the operation finishes in under ~2s — a
|
||||
completion sound for something that felt instant is noise.
|
||||
|
||||
**Both steps:**
|
||||
|
||||
- must not: **fake progress.** No indeterminate bar dressed up as determinate, no
|
||||
bar that advances on a timer, no "estimated" percentage that is really elapsed
|
||||
time. If a total is unknown, show the count that *is* known ("47 files found")
|
||||
rather than a fabricated fraction. This is the project's first test and the
|
||||
easiest place in the fleet to violate it.
|
||||
- must not: add a variable/random reward. There is no real pool here; any
|
||||
variance would be manufactured.
|
||||
- must not: change what the index actually contains, or filter/sort semantics
|
||||
(`web/src/lib/filter-sort.ts`, `app/lib/util/filter_sort.dart` mirror each
|
||||
other — a change to one desyncs the port).
|
||||
- must not: use npm in `web/`. **pnpm only.**
|
||||
- must not: let web and Flutter drift further apart. If you add a progress
|
||||
callback on one side, add the equivalent on the other, or state explicitly in
|
||||
the summary why not.
|
||||
|
||||
- optional: skeleton screens for the directory listing instead of "Loading…".
|
||||
Genuinely useful for perceived latency, but lower value than cancel + real
|
||||
counts. Do it only after step 1 lands.
|
||||
|
||||
## done
|
||||
|
||||
1. Starting a cloud index shows a live, **true** count and can be cancelled — on
|
||||
both web and Flutter.
|
||||
2. A multi-file ZIP shows per-file progress and can be cancelled.
|
||||
3. `web/src/index.css` has a `prefers-reduced-motion` block; with the OS setting
|
||||
on, transitions are suppressed.
|
||||
4. Long-press multi-select produces a haptic on the phone.
|
||||
5. `cd ~/src/dufs-cloud/web && pnpm install && pnpm build && pnpm test` passes
|
||||
(use the repo's actual script names).
|
||||
6. `cd ~/src/dufs-cloud/app && flutter analyze && flutter test` clean.
|
||||
7. No fabricated progress value anywhere in the diff.
|
||||
|
||||
## verify
|
||||
|
||||
**Both surfaces — this repo has two and they drift.**
|
||||
|
||||
Web: run the dev server, open it in a real browser, and index a directory large
|
||||
enough to take several seconds. Confirm the count moves, the cancel works, and
|
||||
the numbers are real (cross-check the final count against the actual file count).
|
||||
A jsdom test cannot verify this — it has no layout and no perceived latency.
|
||||
|
||||
Flutter, on the phone:
|
||||
```
|
||||
adb devices
|
||||
cd ~/src/dufs-cloud/app
|
||||
flutter build apk --release
|
||||
adb install -r build/app/outputs/flutter-apk/app-release.apk
|
||||
```
|
||||
Never `adb uninstall`/`pm clear` — the app holds a stored credential.
|
||||
|
||||
Then trigger a real index build and a real multi-file ZIP on the device and
|
||||
report what you saw. State the haptic as a physical observation.
|
||||
|
||||
## read first
|
||||
|
||||
- `web/src/hooks/use-cloud-index.ts` and `app/lib/services/cloud_index.dart` —
|
||||
read both before changing either; they mirror each other deliberately.
|
||||
- `web/src/lib/download.ts` and `app/lib/services/download_zip.dart` — same.
|
||||
- `web/src/components/gallery.tsx` — the `setBusy` call sites; they are the
|
||||
inventory of every operation currently reduced to one static string.
|
||||
- `~/src/dufs-cloud/DESIGN_AUDIT_TODO.md` — prior findings for this repo.
|
||||
- `~/src/utils/unified-design-system/motion.md` — vocabulary from prompt 01.
|
||||
|
||||
## context you would otherwise rediscover
|
||||
|
||||
- **Prompt 07 must have run first.** Until it does, `app/` has no
|
||||
`design_system` dependency and hand-restates the palette in
|
||||
`app/lib/ui/theme.dart`.
|
||||
- Web tokens come from `@kuhyx/web-ui` imported in `web/src/main.tsx`;
|
||||
`web/src/index.css` declares only `--overlay`. New tokens arrive **only** after
|
||||
the tag bump — this is the step that is easy to forget and silently produces
|
||||
"the variable doesn't exist".
|
||||
- `upload()` uses a non-streaming `_http.put`, so real byte-level upload progress
|
||||
needs a `StreamedRequest` refactor. That may be more than this prompt wants —
|
||||
if you skip it, say so explicitly and report per-**file** progress instead of
|
||||
per-byte rather than faking a byte percentage.
|
||||
- Media playback (`web/src/hooks/use-dash-player.ts`, `web/src/lib/dash-buffer.ts`,
|
||||
`app/lib/services/app_player.dart`) has its own buffering states. Out of scope
|
||||
unless trivially adjacent.
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
@ -1,164 +0,0 @@
|
||||
Add motion and haptics to the wake-alarm phone app
|
||||
|
||||
## findings (2, ranked by value over effort)
|
||||
|
||||
1. **Setting an alarm gives no confirmation or haptic** — and since the real
|
||||
alarm lives in another app entirely, confirming the handoff is the only
|
||||
reassurance this app can offer.
|
||||
2. **Zero motion, and the thinnest local theme in the fleet** — it lacks
|
||||
`AppSpacing`/`AppRadius`/`AppTextSize`, so widgets copied from sibling apps
|
||||
will not compile here.
|
||||
|
||||
## what
|
||||
|
||||
`~/src/wake-alarm/phone_app` is the smallest surface in this project: 12 Dart files,
|
||||
a sync companion that sets a time, syncs it, and hands off to the system clock
|
||||
app. It has **zero motion, zero haptics, zero audio, zero progress feedback** —
|
||||
verified 2026-08-16. Its settings screen is a 56-line `StatelessWidget` with two
|
||||
`ListTile`s and no state at all.
|
||||
|
||||
Bring it up to the fleet's baseline: a confirmation the alarm was actually set,
|
||||
motion tokens on its transitions, and a haptic on the one action that matters.
|
||||
|
||||
**This app is deliberately last and deliberately small.** It also carries the
|
||||
project's sharpest hazard — see `must not` about alarm audio.
|
||||
|
||||
## where
|
||||
|
||||
Repo: `~/src/wake-alarm`. Flutter app: `~/src/wake-alarm/phone_app` (**underscore**, not
|
||||
`phone-app`). The repo root is a Python package; the Flutter app is only the
|
||||
`phone_app/` subdirectory.
|
||||
|
||||
Primary:
|
||||
- `phone_app/lib/screens/home_screen.dart` — `_setPhoneAlarm()` ~:86-96 builds an
|
||||
`AndroidIntent(action: 'android.intent.action.SET_ALARM', arguments: {HOUR,
|
||||
MINUTES, SKIP_UI: false, MESSAGE: 'Wake Alarm'})` via `android_intent_plus`.
|
||||
The `CircularProgressIndicator` is at ~:189. The only `Duration` in the whole
|
||||
app is `.timeout(const Duration(seconds: 3))` ~:95 — **an intent timeout, not
|
||||
animation. Do not tokenise it.**
|
||||
- `phone_app/lib/screens/settings_screen.dart` — 56 lines, `StatelessWidget`,
|
||||
`ListView` ~:21-53, `MaterialPageRoute` ~:30, :49.
|
||||
- `phone_app/lib/services/sync_settings.dart` — holds **only** the GitHub token
|
||||
in the keystore (`FlutterSecureStorage`, `_secureTokenKey = 'github_token'`
|
||||
~:31, `load()` ~:36, `save()` ~:61). There is no general app-preferences class.
|
||||
- `phone_app/lib/ui/theme.dart` — local hand-copy, **no `design_system`
|
||||
dependency**. `buildAppTheme()` ~:12, `AppStatusColors` ~:54. ⚠️ Unlike the
|
||||
other apps it does **not** declare `AppSpacing`/`AppRadius`/`AppTextSize`, so a
|
||||
widget copied from diet-guard or workout_app **will not compile here**. Wired at
|
||||
`phone_app/lib/main.dart` ~:18.
|
||||
|
||||
## must
|
||||
|
||||
**Step 1 — motion + haptics (no new dependency).**
|
||||
|
||||
- Add `HapticFeedback.mediumImpact()` when the alarm is successfully set — the
|
||||
single most important action in the app. Fire it on the tap, not after the
|
||||
intent round-trips. `HapticFeedback` is in `package:flutter/services.dart`; add
|
||||
`<uses-permission android:name="android.permission.VIBRATE"/>` to
|
||||
`phone_app/android/app/src/main/AndroidManifest.xml` if absent.
|
||||
- Give setting the alarm a **clear confirmation**: which time was set, and that
|
||||
the handoff to the system clock succeeded. Today the user gets a `_status`
|
||||
string and a spinner. Since the actual alarm lives in another app, confirming
|
||||
the handoff is the only reassurance this app can offer — make it unambiguous.
|
||||
- Transcribe the motion tokens from prompt 01 into `phone_app/lib/ui/theme.dart`,
|
||||
matching the existing hand-copy convention. Add `AppSpacing`/`AppRadius`/
|
||||
`AppTextSize` **only if** you need them; otherwise leave the file's shape alone.
|
||||
- Apply motion tokens to the two `MaterialPageRoute` transitions.
|
||||
- Honour `MediaQuery.of(context).disableAnimations`.
|
||||
|
||||
**Step 2 — sound (optional here; consider skipping).**
|
||||
|
||||
- A UI confirmation cue would need an audio dependency, an `assets:` block, a
|
||||
settings toggle, **and** a preferences class that does not exist yet (see
|
||||
below). For an app whose entire job is to hand off to another app, that is a
|
||||
poor value ratio.
|
||||
- **Recommendation: skip sound in this app** and say so explicitly in the session
|
||||
summary. If you do add it, it needs the full opt-out toggle like everywhere
|
||||
else — and the toggle work below.
|
||||
- To add any toggle here you must do three things the other apps do not require:
|
||||
(a) convert `SettingsScreen` from `StatelessWidget` to `StatefulWidget`,
|
||||
(b) create a preferences class — there is no `AppSettings` equivalent; model it
|
||||
on `~/src/todo/lib/data/app_settings.dart`, and note `shared_preferences: ^2.3.0`
|
||||
is **already a dependency**, (c) add the `SwitchListTile` to the `ListView`.
|
||||
|
||||
**Both steps:**
|
||||
|
||||
- must not: **touch anything under `~/src/wake-alarm/wake_alarm/`.** That is the
|
||||
desktop Python daemon that produces the real alarm noise — sine-tone WAVs via
|
||||
`paplay` → `aplay` → `speaker-test` (`_audio.py`, `_play_on_all_sinks` ~:218,
|
||||
pcspkr evdev fallback `_beep_pcspkr` ~:81), escalating 440Hz → 1000Hz → loud in
|
||||
the loop at `_alarm.py` ~:393-407, with sink volume save/restore in `_sinks.py`.
|
||||
**Never route a UI-sound opt-out through any of it.** A toggle that silences a
|
||||
wake-up alarm is a bug that makes someone late for work.
|
||||
- The two systems never share a process, package, or file — `phone_app` has no
|
||||
audio engine at all — so a UI sound layer here **structurally cannot** touch
|
||||
alarm playback. Keep it that way; do not build a bridge between them.
|
||||
- must not: change the alarm-setting intent, its arguments, or the 3s timeout.
|
||||
Feedback wraps that call; it does not modify it.
|
||||
- must not: add a streak, a counter, or any reward for setting an alarm. Waking
|
||||
up is not a metric this app owns, and rewarding "alarms set" rewards the wrong
|
||||
act entirely.
|
||||
- must not: copy a widget from another app without checking it only uses symbols
|
||||
this app's thinner theme actually declares.
|
||||
|
||||
## done
|
||||
|
||||
1. Setting an alarm produces a haptic and an unambiguous confirmation naming the
|
||||
time that was set.
|
||||
2. Page transitions use the shared motion tokens; no inline animation `Duration`
|
||||
remains (the 3s intent timeout stays).
|
||||
3. `cd ~/src/wake-alarm/phone_app && flutter analyze` is clean.
|
||||
4. `cd ~/src/wake-alarm/phone_app && flutter test` passes.
|
||||
5. With OS "remove animations" enabled, the app works and durations are zero.
|
||||
6. `git diff` shows **no changes under `~/src/wake-alarm/wake_alarm/`**.
|
||||
7. The summary states whether sound was added or deliberately skipped.
|
||||
|
||||
## verify
|
||||
|
||||
**On the phone.**
|
||||
|
||||
```
|
||||
adb devices # confirm 23181JEGR08034
|
||||
cd ~/src/wake-alarm/phone_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` — the app stores
|
||||
a GitHub token in the keystore and wiping it means re-authenticating.
|
||||
|
||||
Then set a real alarm on the device: confirm the haptic fired (state it as a
|
||||
physical observation), the confirmation named the right time, and **the system
|
||||
Clock app actually received the alarm**. The last one is the real test — this
|
||||
app's job is the handoff, so verify the alarm exists in the Clock app, not just
|
||||
that this screen said so.
|
||||
|
||||
Do **not** test by letting an alarm actually fire unless kuhy asks — that
|
||||
involves the desktop daemon and real noise.
|
||||
|
||||
## read first
|
||||
|
||||
- `phone_app/lib/screens/home_screen.dart` — `_setPhoneAlarm()` in full,
|
||||
including the timeout and the `_status` string handling.
|
||||
- `phone_app/lib/ui/theme.dart` — note what it does **not** declare before
|
||||
writing any widget.
|
||||
- `~/src/todo/lib/data/app_settings.dart` — the preferences-class pattern, only if
|
||||
you decide to do step 2.
|
||||
- `~/src/utils/unified-design-system/motion.md` — vocabulary from prompt 01.
|
||||
**Prompt 01 must have run first.**
|
||||
|
||||
## context you would otherwise rediscover
|
||||
|
||||
- **The Flutter app produces no audio whatsoever.** No audio package, no
|
||||
`assets:` block; `assets/` contains only launcher icons. It fires an Android
|
||||
`SET_ALARM` intent and the **system Clock app** rings. The desktop Python
|
||||
daemon is an entirely separate program on a different machine.
|
||||
- The repo root (`~/src/wake-alarm/wake_alarm/`, `wake_alarm.egg-info`) is the Python
|
||||
package. Only `phone_app/` is Flutter.
|
||||
- This app has **no `design_system` dependency** and the thinnest local theme in
|
||||
the fleet. Adding the dependency is out of scope here (see prompt 07 for what
|
||||
that migration looks like when done properly).
|
||||
- No streaks, no charts, no progress, no completion counts exist — and none
|
||||
should be added. This is a two-button utility.
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
@ -1,209 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify every path and symbol cited in the dopamine-ux prompt files.
|
||||
|
||||
A copy-pasteable prompt that points at a file which does not exist is worse than
|
||||
no prompt, so this gate adjudicates with an exit code rather than by eye.
|
||||
|
||||
Two checks:
|
||||
1. Every cited on-disk path resolves.
|
||||
2. Every cited symbol is greppable in the file it is attributed to. A path
|
||||
check alone cannot catch a stale line number; a symbol grep can.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROMPT_DIR = Path.home() / "src/utils" / "dopamine-ux"
|
||||
HOME = Path.home()
|
||||
|
||||
PATH_RE = re.compile(r"`(~?/[^`\s]+|[\w.\-]+/[\w./\-]+\.\w+)`")
|
||||
|
||||
# Not on-disk paths despite matching the shape above.
|
||||
SKIP_SUBSTRINGS = (
|
||||
"://",
|
||||
"$",
|
||||
"*",
|
||||
"<",
|
||||
">",
|
||||
"…",
|
||||
"devices/", # Firebase/crdt key path
|
||||
"settings/advanced", # Firebase key path
|
||||
"android.permission",
|
||||
"android.intent",
|
||||
"/sdcard/",
|
||||
"/etc/",
|
||||
"/dev/input/",
|
||||
"/opt/",
|
||||
"com.kuhy",
|
||||
"app-release.apk",
|
||||
"build/app/outputs", # build outputs
|
||||
"flutter/services.dart",
|
||||
"flutter/material.dart", # package imports
|
||||
)
|
||||
|
||||
# Files a prompt CREATES. Citing them forward is correct, so absence is expected
|
||||
# -- but pairing each with its creating prompt still catches a typo.
|
||||
CREATED_BY_PROMPT = {
|
||||
"~/src/utils/unified-design-system/motion.md": "01",
|
||||
"~/src/utils/unified-design-system/scripts/structural_check.py": "02",
|
||||
}
|
||||
|
||||
# Cited precisely to state that they do NOT exist.
|
||||
ASSERTED_ABSENT = {
|
||||
"~/gatelock",
|
||||
# Distributed prompt copies are `git rm`d on completion (see the protocol in
|
||||
# 00-INDEX.md), but 00-INDEX.md keeps citing them in its "Distributed copies"
|
||||
# table. Asserting ABSENT rather than dropping the row keeps the check
|
||||
# two-directional: a finished prompt reappearing in a repo is itself a
|
||||
# failure, because the next session would re-run it. Move a row here as each
|
||||
# prompt completes.
|
||||
"~/src/utils/prompts/dopamine-ux-01-motion-tokens.md",
|
||||
}
|
||||
|
||||
# Generic prose fragments rather than citations of one specific file.
|
||||
PROSE_FRAGMENTS = {"lib/ui/theme.dart", "lib/main.dart", "pubspec.yaml"}
|
||||
|
||||
# Symbols each prompt claims exist, as (repo-relative file, symbol).
|
||||
SYMBOL_CLAIMS = [
|
||||
("~/src/diet-guard/app/lib/screens/log_meal_screen.dart", "_onLogMeal"),
|
||||
("~/src/diet-guard/app/lib/widgets/today_progress_card.dart", "TodayProgressCard"),
|
||||
("~/src/diet-guard/app/lib/widgets/streak_summary_row.dart", "StreakSummaryRow"),
|
||||
("~/src/diet-guard/app/lib/screens/log_meal_progress.dart", "buildTodayProgress"),
|
||||
("~/src/diet-guard/app/lib/services/app_settings_service.dart", "_writeToDisk"),
|
||||
("~/src/screen-locker/screen_locker/_status_types.py", "StatusSnapshot"),
|
||||
("~/src/screen-locker/screen_locker/_extra_benefits.py", "process_week_transition"),
|
||||
("~/src/screen-locker/screen_locker/_extra_benefits.py", "current_streak"),
|
||||
("~/src/screen-locker/screen_locker/_unlock_view.py", "unlock_screen"),
|
||||
("~/src/screen-locker/screen_locker/_log_mixin.py", "write_signed_entry"),
|
||||
("~/src/screen-locker/screen_locker/tests/conftest.py", "_ISOLATED_STATE"),
|
||||
("~/src/screen-locker/screen_locker/_weekly_check.py", "WEEKLY_WORKOUT_MINIMUM"),
|
||||
(
|
||||
"~/src/screen-locker/stronglift_replacement/workout_app/lib/screens/"
|
||||
"workout_screen_session.dart",
|
||||
"_playBreakEndCue",
|
||||
),
|
||||
(
|
||||
"~/src/screen-locker/stronglift_replacement/workout_app/lib/screens/"
|
||||
"workout_screen_finish.dart",
|
||||
"_persistFinishedWorkout",
|
||||
),
|
||||
(
|
||||
"~/src/screen-locker/stronglift_replacement/workout_app/lib/widgets/"
|
||||
"workout_summary_dialog.dart",
|
||||
"WorkoutSummaryDialog",
|
||||
),
|
||||
(
|
||||
"~/src/screen-locker/stronglift_replacement/workout_app/lib/services/"
|
||||
"storage_service.dart",
|
||||
"_getSetting",
|
||||
),
|
||||
(
|
||||
"~/src/screen-locker/stronglift_replacement/workout_app/lib/widgets/"
|
||||
"exercise_tile_rows.dart",
|
||||
"AnimatedContainer",
|
||||
),
|
||||
("~/src/todo/lib/data/app_settings.dart", "withAdvancedMode"),
|
||||
("~/src/todo/lib/ui/settings_screen.dart", "SwitchListTile"),
|
||||
("~/src/dufs-cloud/web/src/hooks/use-cloud-index.ts", "walkInto"),
|
||||
("~/src/dufs-cloud/app/lib/services/cloud_index.dart", "buildCloudIndex"),
|
||||
("~/src/dufs-cloud/web/src/lib/download.ts", "buildSelectionZip"),
|
||||
("~/src/dufs-cloud/app/lib/services/download_zip.dart", "buildSelectionZip"),
|
||||
("~/src/wake-alarm/phone_app/lib/screens/home_screen.dart", "_setPhoneAlarm"),
|
||||
("~/src/wake-alarm/wake_alarm/_audio.py", "_play_on_all_sinks"),
|
||||
("~/src/utils/unified-design-system/scripts/palette_check.py", "NON_COLOUR_CSS"),
|
||||
("~/src/utils/unified-design-system/scripts/palette_map.py", "PALETTE"),
|
||||
("~/src/utils/design_system/lib/src/feedback.dart", "showToast"),
|
||||
("~/src/utils/design_system/lib/src/tokens.dart", "AppSpacing"),
|
||||
("~/src/utils/gatelock/gatelock/_window.py", "LockConfig"),
|
||||
]
|
||||
|
||||
|
||||
def expand(raw: str) -> Path:
|
||||
return HOME / raw[2:] if raw.startswith("~/") else Path(raw)
|
||||
|
||||
|
||||
def candidate_paths(text: str) -> set[str]:
|
||||
return {
|
||||
raw
|
||||
for raw in PATH_RE.findall(text)
|
||||
if not any(s in raw for s in SKIP_SUBSTRINGS)
|
||||
and raw not in PROSE_FRAGMENTS
|
||||
and not re.search(r"\.\w+:\d+$", raw) # trailing :line suffix
|
||||
}
|
||||
|
||||
|
||||
def prompt_roots(text: str) -> list[Path]:
|
||||
roots = []
|
||||
for label in ("Repo", "App", "Flutter app"):
|
||||
for m in re.finditer(rf"{label}: `(~/[^`]+)`", text):
|
||||
roots.append(expand(m.group(1)))
|
||||
return roots
|
||||
|
||||
|
||||
def check_paths() -> list[str]:
|
||||
failures = []
|
||||
checked = 0
|
||||
for prompt in sorted(PROMPT_DIR.glob("*.md")):
|
||||
text = prompt.read_text()
|
||||
roots = prompt_roots(text)
|
||||
for raw in sorted(candidate_paths(text)):
|
||||
if raw in ASSERTED_ABSENT:
|
||||
if expand(raw).exists():
|
||||
failures.append(
|
||||
f"{prompt.name}: `{raw}` is asserted ABSENT but EXISTS"
|
||||
)
|
||||
continue
|
||||
if raw in CREATED_BY_PROMPT:
|
||||
owner = CREATED_BY_PROMPT[raw]
|
||||
cites_owner = re.search(rf"[Pp]rompt {owner}", text) is not None
|
||||
if not prompt.name.startswith(owner) and not cites_owner:
|
||||
failures.append(
|
||||
f"{prompt.name}: cites `{raw}` without noting prompt "
|
||||
f"{owner} creates it"
|
||||
)
|
||||
continue
|
||||
|
||||
checked += 1
|
||||
if raw.startswith(("~/", "/")):
|
||||
if not expand(raw).exists():
|
||||
failures.append(f"{prompt.name}: `{raw}` MISSING")
|
||||
elif not any((r / raw).exists() for r in roots):
|
||||
failures.append(
|
||||
f"{prompt.name}: `{raw}` not found under any of "
|
||||
f"{[str(r) for r in roots]}"
|
||||
)
|
||||
print(f"paths: checked {checked}")
|
||||
return failures
|
||||
|
||||
|
||||
def check_symbols() -> list[str]:
|
||||
failures = []
|
||||
for raw_file, symbol in SYMBOL_CLAIMS:
|
||||
path = expand(raw_file)
|
||||
if not path.exists():
|
||||
failures.append(f"symbol source MISSING: {raw_file}")
|
||||
continue
|
||||
if symbol not in path.read_text(errors="replace"):
|
||||
failures.append(f"symbol `{symbol}` NOT FOUND in {raw_file}")
|
||||
print(f"symbols: checked {len(SYMBOL_CLAIMS)}")
|
||||
return failures
|
||||
|
||||
|
||||
def main() -> int:
|
||||
n_prompts = len(list(PROMPT_DIR.glob("*.md")))
|
||||
print(f"verifying {n_prompts} prompt files in {PROMPT_DIR}")
|
||||
failures = check_paths() + check_symbols()
|
||||
if failures:
|
||||
print(f"\nFAIL -- {len(failures)} problem(s):")
|
||||
for f in failures:
|
||||
print(f" {f}")
|
||||
return 1
|
||||
print("\nPASS -- every cited path exists and every cited symbol is present")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -189,16 +189,17 @@ How far to trust a gate is computed from its ledger, never listed by name:
|
||||
`workout_match` counts a `runnerup_tcx` row by `completed_at`, a `rest_day` row
|
||||
on its `day` only if `detail.declared_at` predates that day (else logged, no).
|
||||
|
||||
## The focus project (0.19.0, cut 180 since 0.20.0)
|
||||
## The focus project (0.19.0, cut 180 since 0.20.0, pays 240 since 0.21.0)
|
||||
|
||||
While `focus_project.active(day)` returns a project, `earners_for` drops the
|
||||
gates it `replaces` (`book` = `reading`) and adds one counted `focus` earner
|
||||
(`max_units` = the day's `daily_on`) paying what they paid (shutdown by their rungs,
|
||||
gaming by `max_gaming_minutes`: 60+60+60 over 120 min = 1.5/min):
|
||||
(`max_units` = the day's `daily_on`). Shutdown pays their rungs (180/120 min); gaming
|
||||
their `max_gaming_minutes` + pure bonuses again (240/120 min = 2/min; full day = 8 h):
|
||||
`floor(summed minutes * pay / daily_minutes)`, never per row. The penalty runs
|
||||
from the start day, cutting all of it, 180 with LeetCode's formerly free hour (0.20.0); maturity never delays it. Ledger `~/.local/share/focus_project/ledger.json`:
|
||||
signed `credit` rows, `entry_id` `<project>-<YYYYMMDD>-m<n>`, `detail.minutes` (positive int),
|
||||
`detail.ended_at` (else `day`). `focus_project` unusable = no project (logged).
|
||||
Unbacked timer time pays 0 from 2026-11-08 (focus_project 0.10.0).
|
||||
|
||||
## Adding a gate
|
||||
|
||||
|
||||
@ -31,9 +31,13 @@ _warned: set[str] = set()
|
||||
class FocusEarner(Earner):
|
||||
"""Counted; ``gaming_minutes``/``shutdown_minutes`` pay a FULL day.
|
||||
|
||||
The gaming base cut is all of it, LeetCode's free hour too (kuhy 2026-11-02).
|
||||
``cut_gaming_minutes``: the gaming base cut, all the replaced gates paid,
|
||||
LeetCode's free hour too (0.20.0). The full-day gaming pay adds that free
|
||||
hour once more, so a full day matches the old one (kuhy 2026-11-02, 0.21.0).
|
||||
"""
|
||||
|
||||
cut_gaming_minutes: int = 0
|
||||
|
||||
def gaming_for(self, units: int) -> int:
|
||||
"""Gaming minutes of ``units`` focused minutes, floored."""
|
||||
return self.capped(units) * self.gaming_minutes // (self.max_units or 1)
|
||||
@ -66,11 +70,18 @@ def daily_on(project: Any, day: date) -> int: # noqa: ANN401
|
||||
|
||||
|
||||
def focus_earner(project: Any, dropped: tuple[Earner, ...], day: date) -> Earner: # noqa: ANN401
|
||||
"""The ``focus`` earner paying what ``dropped`` paid, over ``project``."""
|
||||
"""The ``focus`` earner paying what ``dropped`` paid, over ``project``.
|
||||
|
||||
Gaming: the cut is every dropped gate's full pay; a full day pays that plus
|
||||
the pure bonuses (no ``penalty_from``) again: 180 + 60 = 240 at 120 min.
|
||||
"""
|
||||
cut = sum(e.max_gaming_minutes for e in dropped)
|
||||
free = tuple(e for e in dropped if e.penalty_from is None)
|
||||
return FocusEarner(
|
||||
name="focus",
|
||||
label="Focus",
|
||||
gaming_minutes=sum(e.max_gaming_minutes for e in dropped),
|
||||
gaming_minutes=cut + sum(e.max_gaming_minutes for e in free),
|
||||
cut_gaming_minutes=cut,
|
||||
shutdown_minutes=sum(
|
||||
shutdown_units_minutes(e, e.max_units or 1, day) for e in dropped
|
||||
),
|
||||
|
||||
@ -25,6 +25,7 @@ from datetime import UTC, date, datetime
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from earned_time._focus import FocusEarner
|
||||
from earned_time._ladder import on_ladder, shutdown_ceiling_for
|
||||
from earned_time._leisure import base_minutes, ceiling_minutes, is_leisure
|
||||
from earned_time._maturity import confirmed_start, penalty_start
|
||||
@ -45,6 +46,16 @@ def _today() -> date:
|
||||
return datetime.now(tz=UTC).astimezone().date()
|
||||
|
||||
|
||||
def _gaming_cut(item: Earner) -> int:
|
||||
"""What a penalty in force takes off the gaming base: all ``item`` can pay.
|
||||
|
||||
A focus earner cuts what the gates it replaced paid; it pays more (0.21.0).
|
||||
"""
|
||||
if isinstance(item, FocusEarner):
|
||||
return item.cut_gaming_minutes
|
||||
return item.max_gaming_minutes
|
||||
|
||||
|
||||
def _penalised(
|
||||
item: Earner, day: date, first_credits: Mapping[str, date | None] | None
|
||||
) -> bool:
|
||||
@ -117,7 +128,7 @@ def _base(
|
||||
penalised = [e for e in registry if _penalised(e, target, first_credits)]
|
||||
return Base(
|
||||
gaming_minutes=base_minutes(leisure=on)
|
||||
- sum(e.max_gaming_minutes for e in penalised),
|
||||
- sum(_gaming_cut(e) for e in penalised),
|
||||
shutdown_minutes=_shutdown_floor(target, registry, penalised),
|
||||
)
|
||||
|
||||
|
||||
@ -22,7 +22,7 @@ from earned_time import (
|
||||
)
|
||||
from earned_time._focus import focus_earner
|
||||
from earned_time.tests._focus_fakes import DAY, START, activate, focus, project, row
|
||||
from earned_time.tests._maturity_rows import ledger_file
|
||||
from earned_time.tests._maturity_rows import ledger_file, signed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -61,20 +61,24 @@ def test_a_project_that_changes_nothing_leaves_the_registry(
|
||||
assert earners_for(DAY) is TUTOR_EARNERS
|
||||
|
||||
|
||||
def test_a_full_day_pays_what_the_replaced_gates_paid(
|
||||
def test_a_full_day_pays_the_cut_plus_the_free_leetcode_hour(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""kuhy 2026-11-02 (0.21.0): 2 gaming minutes per focus minute, 240 a day."""
|
||||
item = focus(monkeypatch)
|
||||
assert item.max_units == 120
|
||||
assert (item.gaming_for(120), item.shutdown_for(120, DAY)) == (180, 180)
|
||||
assert (item.gaming_for(999), item.shutdown_for(999, DAY)) == (180, 180)
|
||||
assert (item.max_gaming_minutes, item.gaming_for(-5)) == (180, 0)
|
||||
assert (item.gaming_for(120), item.shutdown_for(120, DAY)) == (240, 180)
|
||||
assert (item.gaming_for(999), item.shutdown_for(999, DAY)) == (240, 180)
|
||||
assert (item.max_gaming_minutes, item.gaming_for(-5)) == (240, 0)
|
||||
assert item.cut_gaming_minutes == 180
|
||||
|
||||
|
||||
def test_pay_is_floored_on_the_summed_minutes(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
item = focus(monkeypatch)
|
||||
assert [item.gaming_for(n) for n in (1, 2, 3, 7)] == [1, 3, 4, 10]
|
||||
assert [item.gaming_for(n) for n in (1, 2, 3, 7)] == [2, 4, 6, 14]
|
||||
assert [item.shutdown_for(n, DAY) for n in (1, 2, 3, 7)] == [1, 3, 4, 10]
|
||||
odd = focus(monkeypatch, daily_minutes=90)
|
||||
assert [odd.gaming_for(n) for n in (1, 2, 90)] == [2, 5, 240]
|
||||
|
||||
|
||||
def test_many_small_rows_pay_what_one_row_pays(
|
||||
@ -86,7 +90,21 @@ def test_many_small_rows_pay_what_one_row_pays(
|
||||
one.write_text(ledger_file(tmp_path, [row(7, 7)]).read_text(encoding="utf-8"))
|
||||
units = credit_units(item, many, key_file, DAY)
|
||||
assert units == credit_units(item, one, key_file, DAY) == 7
|
||||
assert item.gaming_for(units or 0) == 10
|
||||
assert item.gaming_for(units or 0) == 14
|
||||
|
||||
|
||||
def test_carry_rows_pay_like_own_rows(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key_file: Path
|
||||
) -> None:
|
||||
"""focus_project 0.6.0 banks overtime as ``<project>-<YYYYMMDD>-carry`` rows."""
|
||||
item = focus(monkeypatch)
|
||||
carry = row(30, 0, carry=True)
|
||||
carry["entry_id"] = f"hr-{DAY:%Y%m%d}-carry"
|
||||
top_up = row(5, 0, carry=True)
|
||||
top_up["entry_id"] = f"hr-{DAY:%Y%m%d}-carry-m35"
|
||||
rows = [signed(carry), signed(top_up), row(85, 85)]
|
||||
ledger = ledger_file(tmp_path, rows)
|
||||
assert credit_units(item, ledger, key_file, DAY) == 120
|
||||
|
||||
|
||||
def test_resolve_prices_base_terms_and_ceiling(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@ -95,26 +113,25 @@ def test_resolve_prices_base_terms_and_ceiling(monkeypatch: pytest.MonkeyPatch)
|
||||
assert nothing.base.gaming_minutes == 300 - 180
|
||||
assert nothing.base.shutdown_minutes == 18 * 60
|
||||
half = resolve({"workout": 0, "focus": 60}, DAY, leisure=False)
|
||||
assert half.gaming_minutes == 120 + 90
|
||||
assert half.gaming_minutes == 120 + 120
|
||||
assert half.shutdown_minutes == 18 * 60 + 90
|
||||
full = resolve({"workout": 1, "focus": 120}, DAY, leisure=False)
|
||||
assert full.shutdown_minutes == 23 * 60
|
||||
assert full.gaming_minutes == 300 - 180 + 240 + 120 == 8 * 60
|
||||
|
||||
|
||||
def test_a_full_project_day_pays_what_the_replaced_gates_paid(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Shutdown matches the old full day; gaming loses LeetCode's free hour."""
|
||||
"""A full project day equals the old full day, LeetCode's free hour too."""
|
||||
activate(monkeypatch, project())
|
||||
done = {"workout": 1, "leetcode": 1, "reading": 1, "tutor": 60, "focus": 120}
|
||||
old = resolve(done, DAY, TUTOR_EARNERS, leisure=False)
|
||||
new = resolve(done, DAY, leisure=False)
|
||||
assert new.shutdown_minutes == old.shutdown_minutes
|
||||
# kuhy 2026-11-02: LeetCode's bonus hour was free on top of the base; the
|
||||
# project day cuts it and pays it back, so the full day nets 60 less.
|
||||
assert new.gaming_minutes == old.gaming_minutes - 60
|
||||
unsolved = resolve({**done, "leetcode": 0}, DAY, TUTOR_EARNERS, leisure=False)
|
||||
assert new.gaming_minutes == unsolved.gaming_minutes
|
||||
# kuhy 2026-11-02 (0.21.0): the day cuts 180 but pays 240, so a full day
|
||||
# is the old full day (8 h); only an idle day is worse.
|
||||
assert new.gaming_minutes == old.gaming_minutes == 8 * 60
|
||||
|
||||
|
||||
def test_an_idle_project_day_also_cuts_the_free_leetcode_hour(
|
||||
@ -166,4 +183,5 @@ def test_the_path_unit_watches_the_focus_ledger() -> None:
|
||||
|
||||
def test_focus_pays_only_replaced_gates_that_exist() -> None:
|
||||
item = focus_earner(project(), (earned_time.LEETCODE,), DAY)
|
||||
assert (item.gaming_minutes, item.shutdown_minutes) == (60, 60)
|
||||
assert (item.gaming_minutes, item.shutdown_minutes) == (120, 60)
|
||||
assert item.cut_gaming_minutes == 60
|
||||
|
||||
88
earned_time/earned_time/tests/test_focus_backing.py
Normal file
88
earned_time/earned_time/tests/test_focus_backing.py
Normal file
@ -0,0 +1,88 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Regression: a timer-only focus day after the anti-cheat cutover pays 0.
|
||||
|
||||
Runs the REAL ``focus_project`` (the sibling package of this monorepo):
|
||||
timer rows -> harvester -> signed ledger -> ``credit_units``. earned_time
|
||||
itself does not change; this pins that the gate reaches pay.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
import importlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from earned_time import credit_units, earners_for
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from types import ModuleType
|
||||
|
||||
DAY = date(2026, 11, 9) # after focus_project.BACKING_FROM
|
||||
_SIBLING = Path(__file__).resolve().parents[3] / "focus_project"
|
||||
|
||||
|
||||
def at(hour: int, minute: int) -> float:
|
||||
return datetime(2026, 11, 9, hour, minute).astimezone().timestamp()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def real_fp(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key_file: Path
|
||||
) -> ModuleType:
|
||||
"""The real focus_project on redirected state (HOME, data dir, key)."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
||||
monkeypatch.setenv("FOCUS_PROJECT_DATA", str(tmp_path / "data"))
|
||||
monkeypatch.setenv("FOCUS_PROJECT_KEY", str(key_file))
|
||||
monkeypatch.setenv("FOCUS_PROJECT_LEETCODE_LEDGER", str(tmp_path / "lc.json"))
|
||||
monkeypatch.delitem(sys.modules, "focus_project") # the conftest's fake
|
||||
monkeypatch.syspath_prepend(str(_SIBLING))
|
||||
fp = importlib.import_module("focus_project")
|
||||
fp.create_project(
|
||||
"hr",
|
||||
name="HR",
|
||||
start=DAY,
|
||||
end=DAY,
|
||||
replaces=["leetcode"],
|
||||
daily_minutes=120,
|
||||
unlock_minutes=30,
|
||||
)
|
||||
return fp
|
||||
|
||||
|
||||
def pay(fp: ModuleType, tmp_path: Path, key_file: Path, *rows: object) -> int | None:
|
||||
log = tmp_path / "data" / "activity.jsonl"
|
||||
log.write_text("".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8")
|
||||
harvest = importlib.import_module("focus_project.harvest")
|
||||
assert harvest.harvest(now=at(23, 0)) == 0
|
||||
(item,) = (e for e in earners_for(DAY) if e.name == "focus")
|
||||
return credit_units(item, tmp_path / "data" / "ledger.json", key_file, DAY)
|
||||
|
||||
|
||||
def row(source: str, kind: str, ts: float) -> dict[str, object]:
|
||||
return {"ts": ts, "source": source, "kind": kind, "project": "hr"}
|
||||
|
||||
|
||||
TIMER = (
|
||||
row("timer", "start", at(9, 0)),
|
||||
row("timer", "checkin", at(9, 5)),
|
||||
row("timer", "stop", at(9, 10)),
|
||||
)
|
||||
|
||||
|
||||
def test_timer_only_day_pays_zero(
|
||||
real_fp: ModuleType, tmp_path: Path, key_file: Path
|
||||
) -> None:
|
||||
assert date(2026, 11, 8) == real_fp.BACKING_FROM
|
||||
assert pay(real_fp, tmp_path, key_file, *TIMER) == 0
|
||||
|
||||
|
||||
def test_backed_timer_day_still_pays(
|
||||
real_fp: ModuleType, tmp_path: Path, key_file: Path
|
||||
) -> None:
|
||||
beat = row("tutor", "heartbeat", at(9, 5))
|
||||
assert pay(real_fp, tmp_path, key_file, *TIMER, beat) == 10
|
||||
@ -70,8 +70,8 @@ def test_a_target_edit_never_reprices_past_days(
|
||||
activate(monkeypatch, project(daily_on=edited, daily_minutes=60))
|
||||
before = earners_for(DAY)[-1]
|
||||
after = earners_for(DAY + timedelta(days=1))[-1]
|
||||
assert (before.max_units, before.gaming_for(60)) == (120, 90)
|
||||
assert (after.max_units, after.gaming_for(60)) == (60, 180)
|
||||
assert (before.max_units, before.gaming_for(60)) == (120, 120)
|
||||
assert (after.max_units, after.gaming_for(60)) == (60, 240)
|
||||
assert (before.shutdown_for(60, DAY), after.shutdown_for(30, DAY)) == (90, 90)
|
||||
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "earned-time"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = []
|
||||
|
||||
143
feedback_inbox/.pre-commit-config.yaml
Normal file
143
feedback_inbox/.pre-commit-config.yaml
Normal file
@ -0,0 +1,143 @@
|
||||
# Pre-commit Configuration for feedback_inbox
|
||||
# Install: pre-commit install && pre-commit install --hook-type pre-push
|
||||
# Run: pre-commit run --all-files
|
||||
# Update: pre-commit autoupdate
|
||||
|
||||
# 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 feedback_inbox/.pre-commit-config.yaml --all-files
|
||||
files: ^feedback_inbox/
|
||||
|
||||
default_language_version:
|
||||
python: python3
|
||||
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-json
|
||||
- id: check-toml
|
||||
- id: check-added-large-files
|
||||
args: [--maxkb=2000]
|
||||
- id: check-merge-conflict
|
||||
- id: detect-private-key
|
||||
- id: debug-statements
|
||||
- id: name-tests-test
|
||||
args: [--pytest-test-first]
|
||||
- id: check-ast
|
||||
- id: mixed-line-ending
|
||||
args: [--fix=lf]
|
||||
- id: requirements-txt-fixer
|
||||
|
||||
- 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 feedback_inbox
|
||||
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-noqa
|
||||
name: Block noqa comments
|
||||
entry: '(?i)#\s*(noqa|type:\s*ignore)'
|
||||
language: pygrep
|
||||
types: [python]
|
||||
- id: no-ruff-noqa
|
||||
name: Block ruff noqa file-level comments
|
||||
entry: '(?i)#\s*ruff:\s*noqa'
|
||||
language: pygrep
|
||||
types: [python]
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.17.4
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes]
|
||||
types_or: [python, pyi]
|
||||
- id: ruff-format
|
||||
types_or: [python, pyi]
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.13.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
args:
|
||||
- --ignore-missing-imports
|
||||
- --no-error-summary
|
||||
- --disable-error-code=no-untyped-def
|
||||
- --disable-error-code=no-untyped-call
|
||||
- --disable-error-code=var-annotated
|
||||
- --disable-error-code=no-any-unimported
|
||||
- --disable-error-code=type-arg
|
||||
- --disable-error-code=no-any-return
|
||||
- --disable-error-code=misc
|
||||
- --disable-error-code=unused-ignore
|
||||
- --disable-error-code=unreachable
|
||||
- --disable-error-code=assignment
|
||||
- --disable-error-code=no-redef
|
||||
- --disable-error-code=attr-defined
|
||||
- --disable-error-code=arg-type
|
||||
- --disable-error-code=union-attr
|
||||
- --disable-error-code=call-overload
|
||||
- --disable-error-code=return-value
|
||||
- --disable-error-code=redundant-cast
|
||||
- --disable-error-code=empty-body
|
||||
- --disable-error-code=list-item
|
||||
|
||||
- repo: https://github.com/pylint-dev/pylint
|
||||
rev: v4.0.8
|
||||
hooks:
|
||||
- id: pylint
|
||||
args:
|
||||
- --rcfile=feedback_inbox/pyproject.toml
|
||||
- --fail-under=10
|
||||
- --jobs=4
|
||||
additional_dependencies:
|
||||
- pytest
|
||||
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.9.4
|
||||
hooks:
|
||||
- id: bandit
|
||||
args:
|
||||
- -c
|
||||
- feedback_inbox/pyproject.toml
|
||||
- --severity-level=high
|
||||
- --confidence-level=medium
|
||||
- --skip=B113
|
||||
additional_dependencies: ["bandit[toml]"]
|
||||
exclude: ^(feedback_inbox/feedback_inbox/tests/|.*test.*\.py$)
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pytest-coverage
|
||||
name: pytest with coverage enforcement
|
||||
entry: scripts/run_subproject_tests.sh feedback_inbox
|
||||
language: script
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
require_serial: true
|
||||
stages: [pre-push]
|
||||
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.3.0
|
||||
hooks:
|
||||
- id: codespell
|
||||
args:
|
||||
- --skip=*.json,*.lock,.git,__pycache__,.venv
|
||||
70
feedback_inbox/README.md
Normal file
70
feedback_inbox/README.md
Normal file
@ -0,0 +1,70 @@
|
||||
# feedback_inbox
|
||||
|
||||
Turns every game's dev-feedback bundle into one todo-app task, so feedback
|
||||
kuhy saves with F12 is never forgotten.
|
||||
|
||||
A game saves `~/data/<repo>_binaries/feedback/<stamp>/` (`report.json`,
|
||||
screenshots, logs) and appends a ready-to-run Claude prompt to
|
||||
`feedback-<date>.md` beside it. This package imports each complete bundle once
|
||||
as a task in the todo app for `kuhyx/<repo>`.
|
||||
|
||||
## Task shape
|
||||
|
||||
- Title: `MANUAL WORK REQUIRED: <repo> feedback: <first line of his note>` (note capped
|
||||
at 120 characters). The prefix makes todoImplementer list it as a human part and never
|
||||
run it. No note: `... feedback: <game> <combo> <HH:MM>`. A `report.json` that does
|
||||
not parse still gets a task, tagged `[corrupt report.json]`; the note then
|
||||
comes from the markdown entry.
|
||||
- Body: a `Due: <next day 10:00>` line, then the bundle's markdown entry (the
|
||||
prompt with absolute paths).
|
||||
- The task is its own note (`add_task` with `new_note`). The server stores that as
|
||||
`<repo> todos: <title>` plus a `## <title>` section, so right after adding, the inbox
|
||||
rewrites it with `update_task` to the exact title and a section-free body (the entry's
|
||||
own `##` headings become `###`). todoImplementer splits notes at `## ` headings, and a
|
||||
title that does not start with `MANUAL WORK REQUIRED: ` would make a runnable part.
|
||||
The marker records `format: 2` once the rewrite worked; a failed rewrite is retried on
|
||||
the next sweep (never a second task), and markers without it are repaired the same way.
|
||||
- Closing the task cancels the feedback. "Act on feedback" in a Claude session
|
||||
means working every open `<repo> feedback:` task.
|
||||
|
||||
The todo app has no due-date or reminder field yet, so the due time is only
|
||||
text in the body.
|
||||
|
||||
## How it runs
|
||||
|
||||
| Unit | Does |
|
||||
|---|---|
|
||||
| `feedback-inbox.path` | `PathChanged` on every existing `*_binaries/feedback` folder; runs `feedback-inbox run` |
|
||||
| `feedback-inbox-sync.path` | `PathChanged` on `~/data` and every `*_binaries` folder; runs `sync` then `run` |
|
||||
| `feedback-inbox-sync.timer` | every 5 min, the same sweep (events during a run are lost by `.path` units) |
|
||||
|
||||
`.path` units take no globs: `feedback-inbox sync` writes the folder list into
|
||||
the drop-ins `feedback-inbox{,-sync}.path.d/dirs.conf` and reloads systemd only
|
||||
when the set of games changed.
|
||||
|
||||
A bundle is complete when `report.json` is at least 2 s old and its markdown
|
||||
entry exists (matched by the stamp folder in the entry's paths). Incomplete
|
||||
bundles are retried; `run` waits up to 15 s for them.
|
||||
|
||||
The import marker is `.inbox-imported.json` inside the bundle (task id, title,
|
||||
due). It is written only after the todo server accepted the task, so a server
|
||||
outage leaves no marker, exits non-zero, and the next sweep retries.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
scripts/run_subproject_tests.sh feedback_inbox # from the monorepo root
|
||||
feedback_inbox/install.sh
|
||||
```
|
||||
|
||||
`install.sh` builds a stdlib-only venv, links `feedback-inbox` into
|
||||
`~/.local/bin`, mints a todo-server token into `~/.config/feedback-inbox/token`
|
||||
(0600; revoke by deleting its line in `~/.config/todo-mcp/tokens`) and enables
|
||||
the units. Enabling sweeps once, so existing bundles are imported then.
|
||||
|
||||
## Tests
|
||||
|
||||
`pytest` from this directory (100 % branch coverage enforced). Tests use a temp
|
||||
`HOME`, a fake todo client, and a local HTTP server for the MCP client; the
|
||||
autouse `conftest.py` redirects the one path resolver and points the todo URL
|
||||
at a dead port.
|
||||
2
feedback_inbox/feedback_inbox/__init__.py
Normal file
2
feedback_inbox/feedback_inbox/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Turns every game dev-feedback bundle into one todo-app task."""
|
||||
15
feedback_inbox/feedback_inbox/__main__.py
Normal file
15
feedback_inbox/feedback_inbox/__main__.py
Normal file
@ -0,0 +1,15 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""``python -m feedback_inbox``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from feedback_inbox._cli import main
|
||||
|
||||
|
||||
def run() -> int:
|
||||
"""Run the CLI on ``sys.argv``."""
|
||||
return main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(run())
|
||||
57
feedback_inbox/feedback_inbox/_bundle.py
Normal file
57
feedback_inbox/feedback_inbox/_bundle.py
Normal file
@ -0,0 +1,57 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Finding games' feedback folders and bundles, and the stamp arithmetic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
BINARIES_SUFFIX = "_binaries"
|
||||
STAMP_FORMAT = "%Y-%m-%dT%H-%M-%S"
|
||||
_STAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}$")
|
||||
_REMINDER_HOUR = 10
|
||||
|
||||
|
||||
def binaries_dirs(data_root: Path) -> list[Path]:
|
||||
"""Every ``<game>_binaries`` directory directly under ``data_root``."""
|
||||
if not data_root.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
p
|
||||
for p in data_root.iterdir()
|
||||
if p.name.endswith(BINARIES_SUFFIX) and p.is_dir()
|
||||
)
|
||||
|
||||
|
||||
def feedback_dirs(data_root: Path) -> list[Path]:
|
||||
"""Every existing ``<game>_binaries/feedback`` directory."""
|
||||
return [
|
||||
d / "feedback" for d in binaries_dirs(data_root) if (d / "feedback").is_dir()
|
||||
]
|
||||
|
||||
|
||||
def repo_name(feedback_dir: Path) -> str:
|
||||
"""The game's repo name: ``cleanup-plate_binaries`` -> ``cleanup-plate``."""
|
||||
return feedback_dir.parent.name.removesuffix(BINARIES_SUFFIX)
|
||||
|
||||
|
||||
def bundles_in(feedback_dir: Path) -> list[Path]:
|
||||
"""Bundle folders (named by a millisecond stamp), oldest first."""
|
||||
return sorted(
|
||||
p for p in feedback_dir.iterdir() if p.is_dir() and _STAMP.match(p.name)
|
||||
)
|
||||
|
||||
|
||||
def parse_stamp(stamp: str) -> datetime:
|
||||
"""The local time a bundle was saved, from its folder name."""
|
||||
return datetime.strptime(stamp[:19], STAMP_FORMAT)
|
||||
|
||||
|
||||
def due_at(stamp: str) -> datetime:
|
||||
"""The reminder time: the day after the bundle, at 10:00 local."""
|
||||
day = parse_stamp(stamp) + timedelta(days=1)
|
||||
return day.replace(hour=_REMINDER_HOUR, minute=0, second=0, microsecond=0)
|
||||
80
feedback_inbox/feedback_inbox/_cli.py
Normal file
80
feedback_inbox/feedback_inbox/_cli.py
Normal file
@ -0,0 +1,80 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""``feedback-inbox run | sync``: what the systemd units execute."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from feedback_inbox._inbox import Importer, Outcome, Waiter, run_settled
|
||||
from feedback_inbox._paths import resolve_paths
|
||||
from feedback_inbox._todo import McpTodoClient
|
||||
from feedback_inbox._units import run_systemctl, sync_units
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
from feedback_inbox._todo import TodoClient
|
||||
|
||||
LOG = logging.getLogger("feedback_inbox")
|
||||
OWNER = "kuhyx"
|
||||
DEFAULT_WAIT = 15.0
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="feedback-inbox")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
run = sub.add_parser("run", help="import new bundles as todo tasks")
|
||||
run.add_argument("--wait", type=float, default=DEFAULT_WAIT)
|
||||
sub.add_parser("sync", help="re-arm the path units for the current games")
|
||||
return parser
|
||||
|
||||
|
||||
def _run(wait: float, client: TodoClient | None) -> int:
|
||||
paths = resolve_paths()
|
||||
todo = client or McpTodoClient(paths.mcp_url, paths.token_file)
|
||||
paths.lock_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Blocking lock: a second run waits, then finds the markers and skips.
|
||||
with paths.lock_file.open("a", encoding="utf-8") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
results = run_settled(
|
||||
paths.data_root,
|
||||
Importer(todo, OWNER),
|
||||
Waiter(wait, time.time, time.sleep),
|
||||
)
|
||||
failed = 0
|
||||
for result in results:
|
||||
if result.outcome is Outcome.FAILED:
|
||||
failed += 1
|
||||
LOG.error("failed %s: %s", result.bundle, result.detail)
|
||||
elif result.outcome is not Outcome.SKIPPED:
|
||||
LOG.info("%s %s %s", result.outcome, result.bundle, result.task_id or "")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
def _sync(systemctl: Callable[[Sequence[str]], int]) -> int:
|
||||
paths = resolve_paths()
|
||||
try:
|
||||
changed = sync_units(paths.data_root, paths.unit_dir, systemctl)
|
||||
except RuntimeError:
|
||||
LOG.exception("sync failed")
|
||||
return 1
|
||||
LOG.info("watch list %s", "rewritten" if changed else "unchanged")
|
||||
return 0
|
||||
|
||||
|
||||
def main(
|
||||
argv: Sequence[str] | None = None,
|
||||
*,
|
||||
client: TodoClient | None = None,
|
||||
systemctl: Callable[[Sequence[str]], int] = run_systemctl,
|
||||
) -> int:
|
||||
"""Entry point; ``client`` and ``systemctl`` are test seams."""
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
args = _parser().parse_args(argv)
|
||||
if args.command == "run":
|
||||
return _run(args.wait, client)
|
||||
return _sync(systemctl)
|
||||
74
feedback_inbox/feedback_inbox/_entry.py
Normal file
74
feedback_inbox/feedback_inbox/_entry.py
Normal file
@ -0,0 +1,74 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""A bundle's markdown entry and its report.json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_SECTION = re.compile(r"(?m)^(?=## )")
|
||||
_GAME = re.compile(r"Game `([^`]*)`, combo `([^`]*)`")
|
||||
_BODY_END = re.compile(r"(?m)^- Screenshot")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Report:
|
||||
"""The fields of report.json the inbox needs."""
|
||||
|
||||
note: str
|
||||
game: str
|
||||
combo: str
|
||||
|
||||
|
||||
def find_entry(feedback_dir: Path, bundle: Path) -> str | None:
|
||||
"""The ``feedback-<date>.md`` section whose paths point into ``bundle``.
|
||||
|
||||
Matched by the stamp folder in its paths, not by the heading time (which
|
||||
drops the milliseconds), so a bundle never picks up a neighbour's entry.
|
||||
"""
|
||||
needle = f"/{bundle.name}/"
|
||||
for md in sorted(feedback_dir.glob("feedback-*.md")):
|
||||
try:
|
||||
text = md.read_text(encoding="utf-8")
|
||||
except OSError, UnicodeDecodeError:
|
||||
continue
|
||||
for section in _SECTION.split(text):
|
||||
if needle in section:
|
||||
return section.strip()
|
||||
return None
|
||||
|
||||
|
||||
def read_report(path: Path) -> Report | None:
|
||||
"""Parse report.json; ``None`` when it cannot be read as a JSON object."""
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except OSError, UnicodeDecodeError, json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return Report(
|
||||
note=_text(data.get("note")),
|
||||
game=_text(data.get("game")),
|
||||
combo=_text(data.get("combo")),
|
||||
)
|
||||
|
||||
|
||||
def _text(value: object) -> str:
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def note_from_entry(entry: str) -> str:
|
||||
"""His note, as written between the heading and the file list."""
|
||||
_head, _, rest = entry.partition("\n")
|
||||
return _BODY_END.split(rest, maxsplit=1)[0].strip()
|
||||
|
||||
|
||||
def game_and_combo(entry: str) -> tuple[str, str]:
|
||||
"""Game and combo from the entry's ``Game ..., combo ...`` line."""
|
||||
found = _GAME.search(entry)
|
||||
return (found.group(1), found.group(2)) if found else ("", "")
|
||||
235
feedback_inbox/feedback_inbox/_inbox.py
Normal file
235
feedback_inbox/feedback_inbox/_inbox.py
Normal file
@ -0,0 +1,235 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Turn complete feedback bundles into todo tasks, once each."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from feedback_inbox._bundle import (
|
||||
bundles_in,
|
||||
due_at,
|
||||
feedback_dirs,
|
||||
parse_stamp,
|
||||
repo_name,
|
||||
)
|
||||
from feedback_inbox._entry import (
|
||||
Report,
|
||||
find_entry,
|
||||
game_and_combo,
|
||||
note_from_entry,
|
||||
read_report,
|
||||
)
|
||||
from feedback_inbox._todo import TodoError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from feedback_inbox._todo import TodoClient
|
||||
|
||||
LOG = logging.getLogger("feedback_inbox")
|
||||
MARKER = ".inbox-imported.json"
|
||||
# Marker format 2: the stored note was rewritten to our exact title and body.
|
||||
FORMAT = 2
|
||||
SETTLE_SECONDS = 2.0
|
||||
TITLE_CAP = 120
|
||||
MANUAL_PREFIX = "MANUAL WORK REQUIRED: "
|
||||
CORRUPT_TAG = " [corrupt report.json]"
|
||||
CORRUPT_LINE = "Warning: report.json is corrupt; the note below comes from the entry."
|
||||
|
||||
|
||||
class Outcome(StrEnum):
|
||||
"""What happened to one bundle in a run."""
|
||||
|
||||
IMPORTED = "imported"
|
||||
SKIPPED = "skipped"
|
||||
PENDING = "pending"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Result:
|
||||
"""One bundle's outcome, for the journal and the exit code."""
|
||||
|
||||
bundle: Path
|
||||
outcome: Outcome
|
||||
task_id: str | None = None
|
||||
detail: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskDraft:
|
||||
"""The task to create: title, body and due time."""
|
||||
|
||||
title: str
|
||||
body: str
|
||||
due: str
|
||||
|
||||
|
||||
def _demote(entry: str) -> str:
|
||||
"""Turn ``## `` headings into ``### ``: no section may start a todo part.
|
||||
|
||||
The todo app splits a note into runnable parts at every ``## `` heading,
|
||||
so the entry's own headings must not look like sections.
|
||||
"""
|
||||
return re.sub(r"(?m)^##(?=\s)", "###", entry)
|
||||
|
||||
|
||||
def _first_line(note: str, fallback: str) -> str:
|
||||
first = next((x.strip() for x in note.splitlines() if x.strip()), "")
|
||||
if len(first) > TITLE_CAP:
|
||||
first = first[: TITLE_CAP - 1] + "…"
|
||||
return first or fallback
|
||||
|
||||
|
||||
def build_task(repo: str, stamp: str, entry: str, report: Report | None) -> TaskDraft:
|
||||
"""Compose the title and body from the entry and (maybe corrupt) report."""
|
||||
found_game, found_combo = game_and_combo(entry)
|
||||
note = note_from_entry(entry) if report is None else report.note
|
||||
game = (report.game if report else "") or found_game
|
||||
combo = (report.combo if report else "") or found_combo
|
||||
clock = parse_stamp(stamp).strftime("%H:%M")
|
||||
fallback = " ".join(x for x in (game, combo, clock) if x)
|
||||
suffix = CORRUPT_TAG if report is None else ""
|
||||
title = f"{MANUAL_PREFIX}{repo} feedback: {_first_line(note, fallback)}{suffix}"
|
||||
due = due_at(stamp)
|
||||
lines = [f"Due: {due:%Y-%m-%d %H:%M}", ""]
|
||||
if report is None:
|
||||
lines += [CORRUPT_LINE, ""]
|
||||
return TaskDraft(title, "\n".join([*lines, _demote(entry)]), due.isoformat())
|
||||
|
||||
|
||||
def _write_marker(bundle: Path, payload: dict[str, object]) -> None:
|
||||
tmp = bundle / f"{MARKER}.tmp"
|
||||
tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
tmp.replace(bundle / MARKER)
|
||||
|
||||
|
||||
def _read_marker(bundle: Path) -> dict[str, object] | None:
|
||||
"""The bundle's marker, or None when it is not a usable marker."""
|
||||
try:
|
||||
data = json.loads((bundle / MARKER).read_text(encoding="utf-8"))
|
||||
except OSError, UnicodeDecodeError, json.JSONDecodeError:
|
||||
return None
|
||||
ok = isinstance(data, dict) and isinstance(data.get("task_id"), str)
|
||||
return data if ok and isinstance(data.get("title"), str) else None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Importer:
|
||||
"""Who files the tasks (``client``) under which GitHub owner."""
|
||||
|
||||
client: TodoClient
|
||||
owner: str
|
||||
settle: float = SETTLE_SECONDS
|
||||
|
||||
def import_bundle(self, feedback_dir: Path, bundle: Path, now: float) -> Result:
|
||||
"""Import one bundle if complete; repair the title of one already filed."""
|
||||
if (bundle / MARKER).exists():
|
||||
return self._repair(feedback_dir, bundle)
|
||||
report_path = bundle / "report.json"
|
||||
entry = find_entry(feedback_dir, bundle)
|
||||
if entry is None or not report_path.is_file():
|
||||
return Result(bundle, Outcome.PENDING, detail="report or entry missing")
|
||||
if now - report_path.stat().st_mtime < self.settle:
|
||||
return Result(bundle, Outcome.PENDING, detail="report.json settling")
|
||||
repo = repo_name(feedback_dir)
|
||||
draft = build_task(repo, bundle.name, entry, read_report(report_path))
|
||||
return self._file(bundle, f"{self.owner}/{repo}", draft)
|
||||
|
||||
def _file(self, bundle: Path, repo: str, draft: TaskDraft) -> Result:
|
||||
"""Create the task, write the marker, then fix the title."""
|
||||
try:
|
||||
ref = self.client.add_task(repo, draft.title, draft.body)
|
||||
except TodoError as exc:
|
||||
return Result(bundle, Outcome.FAILED, detail=str(exc))
|
||||
# Marker first: once the task exists a retry must never add a second.
|
||||
# A merged task belongs to another note, which is not ours to rewrite.
|
||||
payload: dict[str, object] = {
|
||||
"task_id": ref.task_id,
|
||||
"title": draft.title,
|
||||
"due": draft.due,
|
||||
"merged": ref.merged,
|
||||
}
|
||||
if ref.merged:
|
||||
payload["format"] = FORMAT
|
||||
try:
|
||||
_write_marker(bundle, payload)
|
||||
except OSError as exc:
|
||||
return Result(
|
||||
bundle,
|
||||
Outcome.FAILED,
|
||||
ref.task_id,
|
||||
f"task {ref.task_id} filed but marker not written: {exc}",
|
||||
)
|
||||
if ref.merged:
|
||||
return Result(
|
||||
bundle, Outcome.IMPORTED, ref.task_id, "merged into an existing note"
|
||||
)
|
||||
return self._finish(bundle, payload, draft)
|
||||
|
||||
def _repair(self, feedback_dir: Path, bundle: Path) -> Result:
|
||||
"""Finish a filed task whose rewrite never completed (or predates it)."""
|
||||
data = _read_marker(bundle)
|
||||
if data is None or data.get("merged") or data.get("format") == FORMAT:
|
||||
return Result(bundle, Outcome.SKIPPED)
|
||||
entry = find_entry(feedback_dir, bundle)
|
||||
if entry is None:
|
||||
return Result(bundle, Outcome.PENDING, detail="entry missing for repair")
|
||||
report = read_report(bundle / "report.json")
|
||||
draft = build_task(repo_name(feedback_dir), bundle.name, entry, report)
|
||||
return self._finish(bundle, data, draft)
|
||||
|
||||
def _finish(
|
||||
self, bundle: Path, data: dict[str, object], draft: TaskDraft
|
||||
) -> Result:
|
||||
"""Rewrite the stored note to the exact title/body, then mark it done."""
|
||||
task_id = str(data["task_id"])
|
||||
try:
|
||||
self.client.update_task(task_id, draft.title, draft.body)
|
||||
except TodoError as exc:
|
||||
return Result(bundle, Outcome.FAILED, task_id, f"update: {exc}")
|
||||
marked = {**data, "title": draft.title, "due": draft.due, "format": FORMAT}
|
||||
try:
|
||||
_write_marker(bundle, marked)
|
||||
except OSError as exc:
|
||||
return Result(bundle, Outcome.FAILED, task_id, f"marker not written: {exc}")
|
||||
return Result(bundle, Outcome.IMPORTED, task_id)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Waiter:
|
||||
"""How long to wait for half-written bundles, and the clock to do it by."""
|
||||
|
||||
wait: float
|
||||
clock: Callable[[], float]
|
||||
sleep: Callable[[float], None]
|
||||
|
||||
|
||||
def run_once(data_root: Path, importer: Importer, now: float) -> list[Result]:
|
||||
"""Import every complete, not yet imported bundle of every game."""
|
||||
return [
|
||||
importer.import_bundle(folder, bundle, now)
|
||||
for folder in feedback_dirs(data_root)
|
||||
for bundle in bundles_in(folder)
|
||||
]
|
||||
|
||||
|
||||
def run_settled(data_root: Path, importer: Importer, waiter: Waiter) -> list[Result]:
|
||||
"""Like :func:`run_once`, retrying up to ``wait`` s while bundles are pending.
|
||||
|
||||
A bundle is written in pieces; the watch fires on the first piece, so a
|
||||
short wait lets the rest land instead of leaving it to the timer.
|
||||
"""
|
||||
deadline = waiter.clock() + waiter.wait
|
||||
while True:
|
||||
results = run_once(data_root, importer, waiter.clock())
|
||||
pending = any(r.outcome is Outcome.PENDING for r in results)
|
||||
if not pending or waiter.clock() >= deadline:
|
||||
return results
|
||||
waiter.sleep(1.0)
|
||||
51
feedback_inbox/feedback_inbox/_paths.py
Normal file
51
feedback_inbox/feedback_inbox/_paths.py
Normal file
@ -0,0 +1,51 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Every on-disk and network location, resolved in one place.
|
||||
|
||||
Tests redirect :func:`resolve_paths` once; nothing else names a real path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
MCP_URL = "http://127.0.0.1:8735/mcp"
|
||||
# Port 9 (discard) refuses connections: a test that forgets to inject a client
|
||||
# fails closed instead of reaching the live todo server.
|
||||
DEAD_URL = "http://127.0.0.1:9/mcp"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Paths:
|
||||
"""Where the inbox reads games, its token, its lock and its units."""
|
||||
|
||||
data_root: Path
|
||||
token_file: Path
|
||||
lock_file: Path
|
||||
unit_dir: Path
|
||||
mcp_url: str
|
||||
|
||||
@classmethod
|
||||
def under(cls, root: Path) -> Paths:
|
||||
"""A throwaway layout below ``root`` that can never reach the live app."""
|
||||
return cls(
|
||||
data_root=root / "data",
|
||||
token_file=root / "token",
|
||||
lock_file=root / "state" / "lock",
|
||||
unit_dir=root / "units",
|
||||
mcp_url=DEAD_URL,
|
||||
)
|
||||
|
||||
|
||||
def resolve_paths(given: Paths | None = None) -> Paths:
|
||||
"""Return ``given`` or the real layout under the user's home."""
|
||||
if given is not None:
|
||||
return given
|
||||
home = Path.home()
|
||||
return Paths(
|
||||
data_root=home / "data",
|
||||
token_file=home / ".config" / "feedback-inbox" / "token",
|
||||
lock_file=home / ".local" / "share" / "feedback-inbox" / "lock",
|
||||
unit_dir=home / ".config" / "systemd" / "user",
|
||||
mcp_url=MCP_URL,
|
||||
)
|
||||
165
feedback_inbox/feedback_inbox/_todo.py
Normal file
165
feedback_inbox/feedback_inbox/_todo.py
Normal file
@ -0,0 +1,165 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The todo app's MCP server as a minimal ``add_task`` client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_PROTOCOL = "2025-03-26"
|
||||
_TIMEOUT = 20
|
||||
_REPLY = re.compile(r"^(added|merged into|appended to) (\S+)")
|
||||
|
||||
|
||||
class TodoError(Exception):
|
||||
"""The todo server could not take the task."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskRef:
|
||||
"""The task a call created (or the note it was merged into)."""
|
||||
|
||||
task_id: str
|
||||
merged: bool
|
||||
|
||||
|
||||
class TodoClient(Protocol):
|
||||
"""What the inbox needs from a todo app."""
|
||||
|
||||
def add_task(self, repo: str, title: str, body: str) -> TaskRef:
|
||||
"""Create one task in its own note and return its id."""
|
||||
raise NotImplementedError
|
||||
|
||||
def update_task(self, task_id: str, title: str, body: str) -> None:
|
||||
"""Replace the task's stored title and body with exactly these."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class McpTodoClient:
|
||||
"""Streamable-HTTP MCP client: initialize, then one ``add_task`` call."""
|
||||
|
||||
def __init__(self, url: str, token_file: Path) -> None:
|
||||
"""Remember where the server and the (lazily read) token are."""
|
||||
self._url = url
|
||||
self._token_file = token_file
|
||||
|
||||
def add_task(self, repo: str, title: str, body: str) -> TaskRef:
|
||||
"""Create a task with its own note via ``add_task``.
|
||||
|
||||
The server stores a ``new_note`` title as ``<name> todos: <title>``;
|
||||
:meth:`update_task` restores the exact title (and drops the
|
||||
``## <title>`` section the server adds) afterwards.
|
||||
"""
|
||||
arguments = {"repo": repo, "title": title, "body": body, "new_note": True}
|
||||
said = self._call("add_task", arguments)
|
||||
found = _REPLY.match(said)
|
||||
if not found:
|
||||
msg = f"add_task refused: {said[:120]}"
|
||||
raise TodoError(msg)
|
||||
return TaskRef(task_id=found.group(2), merged=found.group(1) != "added")
|
||||
|
||||
def update_task(self, task_id: str, title: str, body: str) -> None:
|
||||
"""Rewrite via ``update_task``: exact title, and body with no sections."""
|
||||
said = self._call("update_task", {"id": task_id, "title": title, "body": body})
|
||||
if not said.startswith("updated"):
|
||||
msg = f"update_task refused: {said[:120]}"
|
||||
raise TodoError(msg)
|
||||
|
||||
def _call(self, tool: str, arguments: dict[str, object]) -> str:
|
||||
token = self._token()
|
||||
session = self._initialize(token)
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": {"name": tool, "arguments": arguments},
|
||||
}
|
||||
return _tool_text(self._post(token, payload, session)[1])
|
||||
|
||||
def _token(self) -> str:
|
||||
try:
|
||||
return self._token_file.read_text(encoding="utf-8").strip()
|
||||
except OSError as exc:
|
||||
msg = f"token file unreadable: {self._token_file}"
|
||||
raise TodoError(msg) from exc
|
||||
|
||||
def _initialize(self, token: str) -> str:
|
||||
init = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": _PROTOCOL,
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "feedback-inbox", "version": "0.1.0"},
|
||||
},
|
||||
}
|
||||
headers, _body = self._post(token, init, None)
|
||||
session = headers.get("mcp-session-id")
|
||||
if not session:
|
||||
msg = "todo server sent no session id"
|
||||
raise TodoError(msg)
|
||||
self._post(
|
||||
token,
|
||||
{"jsonrpc": "2.0", "method": "notifications/initialized"},
|
||||
session,
|
||||
)
|
||||
return session
|
||||
|
||||
def _post(
|
||||
self,
|
||||
token: str,
|
||||
payload: dict[str, object],
|
||||
session: str | None,
|
||||
) -> tuple[dict[str, str], str]:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
}
|
||||
if session:
|
||||
headers["mcp-session-id"] = session
|
||||
request = urllib.request.Request(
|
||||
self._url,
|
||||
data=json.dumps(payload).encode(),
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=_TIMEOUT) as resp:
|
||||
return (
|
||||
{k.lower(): v for k, v in resp.headers.items()},
|
||||
resp.read().decode(),
|
||||
)
|
||||
except urllib.error.HTTPError as exc:
|
||||
exc.close()
|
||||
msg = f"todo server answered HTTP {exc.code}"
|
||||
raise TodoError(msg) from exc
|
||||
except OSError as exc:
|
||||
msg = f"todo server unreachable: {exc}"
|
||||
raise TodoError(msg) from exc
|
||||
|
||||
|
||||
def _tool_text(body: str) -> str:
|
||||
"""The text a tool call answered; an MCP-level error becomes TodoError."""
|
||||
text = body.strip()
|
||||
if not text.startswith("{"):
|
||||
lines = [x[5:].strip() for x in text.splitlines() if x.startswith("data:")]
|
||||
text = next((x for x in lines if x.startswith("{")), "")
|
||||
try:
|
||||
result = json.loads(text)["result"]
|
||||
said = str(result["content"][0]["text"])
|
||||
failed = bool(result.get("isError"))
|
||||
except (ValueError, KeyError, IndexError, TypeError) as exc:
|
||||
msg = f"unparsable MCP reply: {body[:80]!r}"
|
||||
raise TodoError(msg) from exc
|
||||
if failed:
|
||||
msg = f"tool refused: {said[:120]}"
|
||||
raise TodoError(msg)
|
||||
return said
|
||||
67
feedback_inbox/feedback_inbox/_units.py
Normal file
67
feedback_inbox/feedback_inbox/_units.py
Normal file
@ -0,0 +1,67 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Keep the path units' watch lists equal to the games that exist.
|
||||
|
||||
systemd ``.path`` units take no globs, so each ``*_binaries`` folder is listed
|
||||
in a generated drop-in. The set changes only when a game is added or removed;
|
||||
the drop-in is rewritten, and systemd reloaded, only then (a reload per tick
|
||||
would be the daemon-reload loop the fleet rules forbid).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from feedback_inbox._bundle import binaries_dirs, feedback_dirs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
WATCH_PATH = "feedback-inbox.path"
|
||||
SYNC_PATH = "feedback-inbox-sync.path"
|
||||
SYSTEMCTL = "/usr/bin/systemctl"
|
||||
_HEADER = "# Generated by `feedback-inbox sync`; do not edit.\n[Path]\n"
|
||||
|
||||
|
||||
def _dropin(unit_dir: Path, unit: str) -> Path:
|
||||
return unit_dir / f"{unit}.d" / "dirs.conf"
|
||||
|
||||
|
||||
def _render(folders: list[Path]) -> str:
|
||||
return _HEADER + "".join(f"PathChanged={d}\n" for d in folders)
|
||||
|
||||
|
||||
def desired(data_root: Path, unit_dir: Path) -> dict[Path, str]:
|
||||
"""The drop-ins that should exist: feedback folders, and game folders."""
|
||||
return {
|
||||
_dropin(unit_dir, WATCH_PATH): _render(feedback_dirs(data_root)),
|
||||
_dropin(unit_dir, SYNC_PATH): _render(binaries_dirs(data_root)),
|
||||
}
|
||||
|
||||
|
||||
def run_systemctl(args: Sequence[str]) -> int:
|
||||
"""Run ``systemctl --user <args>`` and return its exit code."""
|
||||
return subprocess.run([SYSTEMCTL, "--user", *args], check=False).returncode
|
||||
|
||||
|
||||
def sync_units(
|
||||
data_root: Path,
|
||||
unit_dir: Path,
|
||||
systemctl: Callable[[Sequence[str]], int],
|
||||
) -> bool:
|
||||
"""Rewrite drifted drop-ins, then reload and re-arm once; True if changed."""
|
||||
changed = False
|
||||
for path, text in desired(data_root, unit_dir).items():
|
||||
if path.is_file() and path.read_text(encoding="utf-8") == text:
|
||||
continue
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
changed = True
|
||||
if not changed:
|
||||
return False
|
||||
for args in (["daemon-reload"], ["restart", WATCH_PATH, SYNC_PATH]):
|
||||
if systemctl(args):
|
||||
msg = f"systemctl --user {' '.join(args)} failed"
|
||||
raise RuntimeError(msg)
|
||||
return True
|
||||
2
feedback_inbox/feedback_inbox/tests/__init__.py
Normal file
2
feedback_inbox/feedback_inbox/tests/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Tests for feedback_inbox."""
|
||||
31
feedback_inbox/feedback_inbox/tests/conftest.py
Normal file
31
feedback_inbox/feedback_inbox/tests/conftest.py
Normal file
@ -0,0 +1,31 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Redirect the one path resolver, so no test can touch real user state.
|
||||
|
||||
Every writer resolves its paths through ``feedback_inbox._paths.resolve_paths``;
|
||||
redirecting that one function moves all of them (kuhy's real ``~/data``,
|
||||
token file, lock and systemd unit dir included).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from feedback_inbox._paths import Paths
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def paths(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Paths:
|
||||
"""Point every default path at ``tmp_path`` before any test runs."""
|
||||
redirected = Paths.under(tmp_path / "home")
|
||||
|
||||
def _resolve(given: Paths | None = None) -> Paths:
|
||||
return given if given is not None else redirected
|
||||
|
||||
monkeypatch.setattr("feedback_inbox._cli.resolve_paths", _resolve)
|
||||
redirected.data_root.mkdir(parents=True)
|
||||
return redirected
|
||||
42
feedback_inbox/feedback_inbox/tests/test_bundle.py
Normal file
42
feedback_inbox/feedback_inbox/tests/test_bundle.py
Normal file
@ -0,0 +1,42 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Discovery, stamps and due dates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from feedback_inbox._bundle import bundles_in, due_at, feedback_dirs, repo_name
|
||||
from feedback_inbox.tests.test_support import make_bundle
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from feedback_inbox._paths import Paths
|
||||
|
||||
|
||||
def test_feedback_dirs_covers_every_game(paths: Paths) -> None:
|
||||
make_bundle(paths.data_root, game="cleanup-plate")
|
||||
make_bundle(paths.data_root, game="dread-grid")
|
||||
(paths.data_root / "other" / "feedback").mkdir(parents=True)
|
||||
(paths.data_root / "empty_binaries").mkdir()
|
||||
(paths.data_root / "file_binaries").write_text("x", encoding="utf-8")
|
||||
found = feedback_dirs(paths.data_root)
|
||||
assert [repo_name(d) for d in found] == ["cleanup-plate", "dread-grid"]
|
||||
|
||||
|
||||
def test_feedback_dirs_without_data_root(paths: Paths) -> None:
|
||||
assert feedback_dirs(paths.data_root / "missing") == []
|
||||
|
||||
|
||||
def test_bundles_in_keeps_only_stamp_folders(paths: Paths) -> None:
|
||||
first = make_bundle(paths.data_root, stamp="2026-11-11T13-53-19-124")
|
||||
make_bundle(paths.data_root, stamp="2026-11-11T13-52-33-162")
|
||||
feedback = first.parent
|
||||
(feedback / "notes").mkdir()
|
||||
(feedback / "2026-11-11T13-52-33-999").write_text("x", encoding="utf-8")
|
||||
names = [b.name for b in bundles_in(feedback)]
|
||||
assert names == ["2026-11-11T13-52-33-162", "2026-11-11T13-53-19-124"]
|
||||
|
||||
|
||||
def test_due_is_next_day_ten(paths: Paths) -> None:
|
||||
assert due_at("2026-11-11T13-52-33-162") == datetime(2026, 11, 12, 10, 0)
|
||||
assert due_at("2026-12-31T23-59-59-001") == datetime(2027, 1, 1, 10, 0)
|
||||
103
feedback_inbox/feedback_inbox/tests/test_cli.py
Normal file
103
feedback_inbox/feedback_inbox/tests/test_cli.py
Normal file
@ -0,0 +1,103 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The command line: run, sync, exit codes, lock."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from feedback_inbox import __main__ as entry
|
||||
from feedback_inbox._cli import main
|
||||
from feedback_inbox._inbox import MARKER
|
||||
from feedback_inbox._paths import Paths, resolve_paths
|
||||
from feedback_inbox.tests.test_support import FakeTodo, make_bundle
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_run_imports_and_exits_zero(
|
||||
paths: Paths, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
bundle = make_bundle(paths.data_root)
|
||||
todo = FakeTodo()
|
||||
with caplog.at_level(logging.INFO):
|
||||
assert main(["run", "--wait", "0"], client=todo) == 0
|
||||
assert (bundle / MARKER).exists()
|
||||
assert len(todo.calls) == 1
|
||||
assert "imported" in caplog.text
|
||||
assert main(["run", "--wait", "0"], client=todo) == 0
|
||||
assert len(todo.calls) == 1
|
||||
|
||||
|
||||
def test_run_fails_loudly_when_todo_is_down(
|
||||
paths: Paths, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
bundle = make_bundle(paths.data_root)
|
||||
assert main(["run", "--wait", "0"], client=FakeTodo(fail=True)) == 1
|
||||
assert not (bundle / MARKER).exists()
|
||||
assert "todo down" in caplog.text
|
||||
|
||||
|
||||
def test_run_pending_is_not_an_error(paths: Paths) -> None:
|
||||
make_bundle(paths.data_root, entry=False)
|
||||
assert main(["run", "--wait", "0"], client=FakeTodo()) == 0
|
||||
|
||||
|
||||
def test_run_builds_the_real_client_from_paths(paths: Paths) -> None:
|
||||
make_bundle(paths.data_root)
|
||||
assert main(["run", "--wait", "0"]) == 1
|
||||
|
||||
|
||||
def test_run_with_nothing_to_do_needs_no_token(paths: Paths) -> None:
|
||||
assert main(["run", "--wait", "0"]) == 0
|
||||
|
||||
|
||||
def test_run_takes_the_lock_file(paths: Paths) -> None:
|
||||
make_bundle(paths.data_root)
|
||||
assert main(["run", "--wait", "0"], client=FakeTodo()) == 0
|
||||
assert paths.lock_file.exists()
|
||||
|
||||
|
||||
def test_sync_command(paths: Paths) -> None:
|
||||
make_bundle(paths.data_root)
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake(args: Sequence[str]) -> int:
|
||||
calls.append(list(args))
|
||||
return 0
|
||||
|
||||
assert main(["sync"], systemctl=fake) == 0
|
||||
assert calls[0] == ["daemon-reload"]
|
||||
|
||||
|
||||
def test_sync_failure_exit_code(paths: Paths, caplog: pytest.LogCaptureFixture) -> None:
|
||||
make_bundle(paths.data_root)
|
||||
assert main(["sync"], systemctl=lambda _a: 1) == 1
|
||||
assert "daemon-reload" in caplog.text
|
||||
|
||||
|
||||
def test_usage_error() -> None:
|
||||
with pytest.raises(SystemExit):
|
||||
main([])
|
||||
|
||||
|
||||
def test_real_paths_are_under_home(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setattr("feedback_inbox._paths.Path.home", lambda: tmp_path)
|
||||
real = resolve_paths()
|
||||
assert real.data_root == tmp_path / "data"
|
||||
assert real.token_file == tmp_path / ".config" / "feedback-inbox" / "token"
|
||||
assert real.unit_dir == tmp_path / ".config" / "systemd" / "user"
|
||||
assert real.mcp_url == "http://127.0.0.1:8735/mcp"
|
||||
given = Paths.under(tmp_path)
|
||||
assert resolve_paths(given) is given
|
||||
|
||||
|
||||
def test_module_entry_point(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("sys.argv", ["feedback-inbox", "run", "--wait", "0"])
|
||||
assert entry.run() == 0
|
||||
68
feedback_inbox/feedback_inbox/tests/test_entry.py
Normal file
68
feedback_inbox/feedback_inbox/tests/test_entry.py
Normal file
@ -0,0 +1,68 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Finding a bundle's markdown entry and reading its report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from feedback_inbox._entry import (
|
||||
find_entry,
|
||||
game_and_combo,
|
||||
note_from_entry,
|
||||
read_report,
|
||||
)
|
||||
from feedback_inbox.tests.test_support import make_bundle
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from feedback_inbox._paths import Paths
|
||||
|
||||
|
||||
def test_find_entry_picks_the_matching_section(paths: Paths) -> None:
|
||||
first = make_bundle(paths.data_root, stamp="2026-11-11T13-52-33-162", note="alpha")
|
||||
second = make_bundle(paths.data_root, stamp="2026-11-11T13-53-19-124", note="beta")
|
||||
entry = find_entry(first.parent, second)
|
||||
assert entry is not None
|
||||
assert "beta" in entry
|
||||
assert "alpha" not in entry
|
||||
assert find_entry(first.parent, first) is not None
|
||||
|
||||
|
||||
def test_find_entry_missing(paths: Paths) -> None:
|
||||
bundle = make_bundle(paths.data_root, entry=False)
|
||||
assert find_entry(bundle.parent, bundle) is None
|
||||
(bundle.parent / "feedback-2026-11-11.md").write_bytes(b"\xff\xfe")
|
||||
assert find_entry(bundle.parent, bundle) is None
|
||||
|
||||
|
||||
def test_read_report(paths: Paths) -> None:
|
||||
bundle = make_bundle(paths.data_root, note=" hello \nworld")
|
||||
report = read_report(bundle / "report.json")
|
||||
assert report is not None
|
||||
assert report.note == " hello \nworld"
|
||||
assert (report.game, report.combo) == ("museum", "loop=P2")
|
||||
|
||||
|
||||
def test_read_report_corrupt_forms(tmp_path: Path) -> None:
|
||||
path = tmp_path / "report.json"
|
||||
for bad in ("{broken", "[1]", '{"note": 3, "game": null}'):
|
||||
path.write_text(bad, encoding="utf-8")
|
||||
report = read_report(path)
|
||||
if bad == '{"note": 3, "game": null}':
|
||||
assert report is not None
|
||||
assert (report.note, report.game, report.combo) == ("", "", "")
|
||||
else:
|
||||
assert report is None
|
||||
path.write_bytes(b"\xff\xfe")
|
||||
assert read_report(path) is None
|
||||
|
||||
|
||||
def test_entry_fallbacks(paths: Paths) -> None:
|
||||
bundle = make_bundle(paths.data_root, note="line one\nline two")
|
||||
entry = find_entry(bundle.parent, bundle)
|
||||
assert entry is not None
|
||||
assert note_from_entry(entry) == "line one\nline two"
|
||||
assert game_and_combo(entry) == ("museum", "loop=P2,carry=d,id=c")
|
||||
assert note_from_entry("## head only") == ""
|
||||
assert game_and_combo("nothing here") == ("", "")
|
||||
194
feedback_inbox/feedback_inbox/tests/test_inbox.py
Normal file
194
feedback_inbox/feedback_inbox/tests/test_inbox.py
Normal file
@ -0,0 +1,194 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Import rules: complete, incomplete, idempotent, titles, due date, failures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from feedback_inbox._entry import Report
|
||||
from feedback_inbox._inbox import (
|
||||
MARKER,
|
||||
Importer,
|
||||
Outcome,
|
||||
Waiter,
|
||||
build_task,
|
||||
run_once,
|
||||
run_settled,
|
||||
)
|
||||
from feedback_inbox.tests.test_support import STAMP, FakeTodo, make_bundle
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from feedback_inbox._paths import Paths
|
||||
|
||||
NOW = 1_000_000.0
|
||||
OWNER = "kuhyx"
|
||||
MANUAL = "MANUAL WORK REQUIRED: "
|
||||
|
||||
|
||||
def _run(paths: Paths, todo: FakeTodo) -> list[Outcome]:
|
||||
return [r.outcome for r in run_once(paths.data_root, Importer(todo, OWNER), NOW)]
|
||||
|
||||
|
||||
def test_complete_bundle_becomes_one_task(paths: Paths) -> None:
|
||||
bundle = make_bundle(paths.data_root)
|
||||
todo = FakeTodo()
|
||||
assert _run(paths, todo) == [Outcome.IMPORTED]
|
||||
((repo, title, body),) = todo.calls
|
||||
assert repo == "kuhyx/cleanup-plate"
|
||||
assert (
|
||||
title
|
||||
== MANUAL + "cleanup-plate feedback: I would like to see the painting IN FULL"
|
||||
)
|
||||
assert body.startswith("Due: 2026-11-12 10:00\n\n### 2026-11-11T13:52:33")
|
||||
assert f"{bundle}/report.json" in body
|
||||
marker = json.loads((bundle / MARKER).read_text(encoding="utf-8"))
|
||||
assert marker["task_id"] == "id0001" + "0" * 28
|
||||
assert marker["due"] == "2026-11-12T10:00:00"
|
||||
|
||||
|
||||
def test_rerun_never_duplicates(paths: Paths) -> None:
|
||||
make_bundle(paths.data_root)
|
||||
todo = FakeTodo()
|
||||
assert _run(paths, todo) == [Outcome.IMPORTED]
|
||||
assert _run(paths, todo) == [Outcome.SKIPPED]
|
||||
assert len(todo.calls) == 1
|
||||
|
||||
|
||||
def test_incomplete_bundles_wait(paths: Paths) -> None:
|
||||
make_bundle(paths.data_root, stamp="2026-11-11T10-00-00-001", report=None)
|
||||
make_bundle(paths.data_root, stamp="2026-11-11T10-00-01-001", entry=False)
|
||||
make_bundle(paths.data_root, stamp="2026-11-11T10-00-02-001", age=0.5)
|
||||
todo = FakeTodo()
|
||||
assert _run(paths, todo) == [Outcome.PENDING] * 3
|
||||
assert todo.calls == []
|
||||
later = run_once(paths.data_root, Importer(todo, OWNER), NOW + 10)
|
||||
assert [r.outcome for r in later] == [
|
||||
Outcome.PENDING,
|
||||
Outcome.PENDING,
|
||||
Outcome.IMPORTED,
|
||||
]
|
||||
|
||||
|
||||
def test_multiple_games(paths: Paths) -> None:
|
||||
make_bundle(paths.data_root, game="cleanup-plate", note="a")
|
||||
make_bundle(paths.data_root, game="dread-grid", note="b")
|
||||
todo = FakeTodo()
|
||||
assert _run(paths, todo) == [Outcome.IMPORTED] * 2
|
||||
assert [(c[0], c[1]) for c in todo.calls] == [
|
||||
("kuhyx/cleanup-plate", "MANUAL WORK REQUIRED: cleanup-plate feedback: a"),
|
||||
("kuhyx/dread-grid", "MANUAL WORK REQUIRED: dread-grid feedback: b"),
|
||||
]
|
||||
|
||||
|
||||
def test_corrupt_report_still_a_task(paths: Paths) -> None:
|
||||
bundle = make_bundle(paths.data_root, report="{broken", note="keep me")
|
||||
todo = FakeTodo()
|
||||
assert _run(paths, todo) == [Outcome.IMPORTED]
|
||||
((_, title, body),) = todo.calls
|
||||
assert (
|
||||
title
|
||||
== "MANUAL WORK REQUIRED: cleanup-plate feedback: keep me [corrupt report.json]"
|
||||
)
|
||||
assert "report.json is corrupt" in body
|
||||
assert (bundle / MARKER).exists()
|
||||
|
||||
|
||||
def test_corrupt_report_without_note(paths: Paths) -> None:
|
||||
make_bundle(paths.data_root, report="[]", note="")
|
||||
todo = FakeTodo()
|
||||
assert _run(paths, todo) == [Outcome.IMPORTED]
|
||||
title = todo.calls[0][1]
|
||||
game_part = "museum loop=P2,carry=d,id=c 13:52"
|
||||
assert title == f"{MANUAL}cleanup-plate feedback: {game_part} [corrupt report.json]"
|
||||
|
||||
|
||||
def test_no_note_title_names_game_combo_time(paths: Paths) -> None:
|
||||
make_bundle(paths.data_root, note="")
|
||||
todo = FakeTodo()
|
||||
assert _run(paths, todo) == [Outcome.IMPORTED]
|
||||
assert (
|
||||
todo.calls[0][1]
|
||||
== "MANUAL WORK REQUIRED: cleanup-plate feedback: museum loop=P2 13:52"
|
||||
)
|
||||
|
||||
|
||||
def test_title_uses_first_line_and_caps_length() -> None:
|
||||
report = Report(note="\n " + "x" * 300 + "\nsecond", game="g", combo="c")
|
||||
draft = build_task("repo", STAMP, "## e", report)
|
||||
assert draft.title == "MANUAL WORK REQUIRED: repo feedback: " + "x" * 119 + "…"
|
||||
bare = build_task("repo", STAMP, "## e", Report(note="", game="", combo=""))
|
||||
assert bare.title == "MANUAL WORK REQUIRED: repo feedback: 13:52"
|
||||
|
||||
|
||||
def test_todo_failure_leaves_no_marker(paths: Paths) -> None:
|
||||
bundle = make_bundle(paths.data_root)
|
||||
todo = FakeTodo(fail=True)
|
||||
results = run_once(paths.data_root, Importer(todo, OWNER), NOW)
|
||||
assert [r.outcome for r in results] == [Outcome.FAILED]
|
||||
assert "todo down" in results[0].detail
|
||||
assert not (bundle / MARKER).exists()
|
||||
assert _run(paths, FakeTodo()) == [Outcome.IMPORTED]
|
||||
|
||||
|
||||
def test_merge_is_flagged_but_marked(paths: Paths) -> None:
|
||||
make_bundle(paths.data_root)
|
||||
results = run_once(paths.data_root, Importer(FakeTodo(merged=True), OWNER), NOW)
|
||||
assert results[0].outcome is Outcome.IMPORTED
|
||||
assert "merged" in results[0].detail
|
||||
|
||||
|
||||
def test_marker_write_failure_is_reported(paths: Paths) -> None:
|
||||
bundle = make_bundle(paths.data_root)
|
||||
(bundle / MARKER).mkdir()
|
||||
results = run_once(paths.data_root, Importer(FakeTodo(), OWNER), NOW)
|
||||
assert results[0].outcome is Outcome.SKIPPED
|
||||
(bundle / MARKER).rmdir()
|
||||
bundle.chmod(0o500)
|
||||
try:
|
||||
results = run_once(paths.data_root, Importer(FakeTodo(), OWNER), NOW)
|
||||
finally:
|
||||
bundle.chmod(0o700)
|
||||
assert results[0].outcome is Outcome.FAILED
|
||||
assert "marker" in results[0].detail
|
||||
|
||||
|
||||
def test_run_settled_waits_then_gives_up(paths: Paths, tmp_path: Path) -> None:
|
||||
make_bundle(paths.data_root, age=0.0, now=NOW)
|
||||
clock = [NOW]
|
||||
slept: list[float] = []
|
||||
|
||||
def sleep(seconds: float) -> None:
|
||||
slept.append(seconds)
|
||||
clock[0] += seconds
|
||||
|
||||
todo = FakeTodo()
|
||||
results = run_settled(
|
||||
paths.data_root, Importer(todo, OWNER), Waiter(5.0, lambda: clock[0], sleep)
|
||||
)
|
||||
assert [r.outcome for r in results] == [Outcome.IMPORTED]
|
||||
assert slept == [1.0, 1.0]
|
||||
|
||||
other = tmp_path / "other"
|
||||
other.mkdir()
|
||||
make_bundle(other, report=None)
|
||||
slept.clear()
|
||||
results = run_settled(
|
||||
other, Importer(todo, OWNER), Waiter(3.0, lambda: clock[0], sleep)
|
||||
)
|
||||
assert [r.outcome for r in results] == [Outcome.PENDING]
|
||||
assert slept == [1.0, 1.0, 1.0]
|
||||
|
||||
|
||||
def test_every_title_starts_with_the_manual_prefix(paths: Paths) -> None:
|
||||
make_bundle(paths.data_root, stamp="2026-11-11T10-00-00-001", note="a")
|
||||
make_bundle(paths.data_root, stamp="2026-11-11T10-00-01-001", note="")
|
||||
make_bundle(paths.data_root, stamp="2026-11-11T10-00-02-001", report="{")
|
||||
make_bundle(paths.data_root, game="dread-grid", note="b")
|
||||
todo = FakeTodo()
|
||||
assert _run(paths, todo) == [Outcome.IMPORTED] * 4
|
||||
titles = [call[1] for call in todo.calls]
|
||||
assert len(titles) == 4
|
||||
assert all(t.startswith("MANUAL WORK REQUIRED: ") for t in titles)
|
||||
145
feedback_inbox/feedback_inbox/tests/test_inbox_repair.py
Normal file
145
feedback_inbox/feedback_inbox/tests/test_inbox_repair.py
Normal file
@ -0,0 +1,145 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Exact titles, section-free bodies, and repair of half-finished tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from feedback_inbox._entry import Report
|
||||
from feedback_inbox._inbox import (
|
||||
FORMAT,
|
||||
MARKER,
|
||||
Importer,
|
||||
Outcome,
|
||||
build_task,
|
||||
run_once,
|
||||
)
|
||||
from feedback_inbox.tests.test_support import STAMP, FakeTodo, make_bundle
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from feedback_inbox._paths import Paths
|
||||
|
||||
NOW = 1_000_000.0
|
||||
OWNER = "kuhyx"
|
||||
MANUAL = "MANUAL WORK REQUIRED: "
|
||||
|
||||
|
||||
def _run(paths: Paths, todo: FakeTodo) -> list[Outcome]:
|
||||
return [r.outcome for r in run_once(paths.data_root, Importer(todo, OWNER), NOW)]
|
||||
|
||||
|
||||
def _marker(bundle: Path) -> dict[str, object]:
|
||||
data: dict[str, object] = json.loads((bundle / MARKER).read_text(encoding="utf-8"))
|
||||
return data
|
||||
|
||||
|
||||
def _no_extra_sections(text: str) -> bool:
|
||||
return not any(line.startswith("## ") for line in text.splitlines())
|
||||
|
||||
|
||||
def test_stored_note_is_one_manual_part(paths: Paths) -> None:
|
||||
bundle = make_bundle(paths.data_root)
|
||||
todo = FakeTodo()
|
||||
assert _run(paths, todo) == [Outcome.IMPORTED]
|
||||
(stored,) = todo.stored.values()
|
||||
assert stored.startswith(MANUAL)
|
||||
assert _no_extra_sections(stored)
|
||||
assert "### 2026-11-11T13:52:33" in stored
|
||||
assert _marker(bundle)["format"] == FORMAT
|
||||
|
||||
|
||||
def test_entry_headings_never_become_sections() -> None:
|
||||
entry = "## a heading\ntext\n## another\n### kept"
|
||||
draft = build_task("r", STAMP, entry, Report(note="n", game="", combo=""))
|
||||
assert _no_extra_sections(draft.body)
|
||||
assert "### a heading\ntext\n### another\n### kept" in draft.body
|
||||
|
||||
|
||||
def test_failed_update_keeps_marker_and_retries_without_a_second_task(
|
||||
paths: Paths,
|
||||
) -> None:
|
||||
bundle = make_bundle(paths.data_root)
|
||||
todo = FakeTodo(fail_update=True)
|
||||
results = run_once(paths.data_root, Importer(todo, OWNER), NOW)
|
||||
assert [r.outcome for r in results] == [Outcome.FAILED]
|
||||
assert "update refused" in results[0].detail
|
||||
assert "format" not in _marker(bundle)
|
||||
todo.fail_update = False
|
||||
assert _run(paths, todo) == [Outcome.IMPORTED]
|
||||
assert len(todo.calls) == 1
|
||||
(stored,) = todo.stored.values()
|
||||
assert stored.startswith(MANUAL)
|
||||
assert _no_extra_sections(stored)
|
||||
assert _run(paths, todo) == [Outcome.SKIPPED]
|
||||
|
||||
|
||||
def test_old_marker_is_repaired_from_the_bundle(paths: Paths) -> None:
|
||||
bundle = make_bundle(paths.data_root)
|
||||
task_id = "abc" + "0" * 33
|
||||
(bundle / MARKER).write_text(
|
||||
json.dumps({"task_id": task_id, "title": "old", "title_fixed": True}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
todo = FakeTodo()
|
||||
assert _run(paths, todo) == [Outcome.IMPORTED]
|
||||
assert todo.calls == []
|
||||
assert todo.stored[task_id].startswith(MANUAL)
|
||||
assert _no_extra_sections(todo.stored[task_id])
|
||||
assert _marker(bundle)["format"] == FORMAT
|
||||
|
||||
|
||||
def test_old_marker_whose_entry_vanished_waits(paths: Paths) -> None:
|
||||
bundle = make_bundle(paths.data_root, entry=False)
|
||||
(bundle / MARKER).write_text(
|
||||
json.dumps({"task_id": "t" * 36, "title": "old"}), encoding="utf-8"
|
||||
)
|
||||
todo = FakeTodo()
|
||||
assert _run(paths, todo) == [Outcome.PENDING]
|
||||
assert todo.updated == []
|
||||
|
||||
|
||||
def test_merged_task_is_not_rewritten(paths: Paths) -> None:
|
||||
bundle = make_bundle(paths.data_root)
|
||||
todo = FakeTodo(merged=True)
|
||||
assert _run(paths, todo) == [Outcome.IMPORTED]
|
||||
assert todo.updated == []
|
||||
assert _marker(bundle)["format"] == FORMAT
|
||||
assert _run(paths, todo) == [Outcome.SKIPPED]
|
||||
|
||||
|
||||
def test_old_merged_marker_is_left_alone(paths: Paths) -> None:
|
||||
bundle = make_bundle(paths.data_root)
|
||||
(bundle / MARKER).write_text(
|
||||
json.dumps({"task_id": "t" * 36, "title": "x", "merged": True}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
todo = FakeTodo()
|
||||
assert _run(paths, todo) == [Outcome.SKIPPED]
|
||||
assert todo.updated == []
|
||||
|
||||
|
||||
def test_unreadable_marker_is_left_alone(paths: Paths) -> None:
|
||||
bundle = make_bundle(paths.data_root)
|
||||
for junk in ("{broken", "[1]", '{"task_id": 5}'):
|
||||
(bundle / MARKER).write_text(junk, encoding="utf-8")
|
||||
todo = FakeTodo()
|
||||
assert _run(paths, todo) == [Outcome.SKIPPED]
|
||||
assert todo.calls == []
|
||||
assert todo.updated == []
|
||||
|
||||
|
||||
def test_repair_reports_an_unwritable_marker(paths: Paths) -> None:
|
||||
bundle = make_bundle(paths.data_root)
|
||||
(bundle / MARKER).write_text(
|
||||
json.dumps({"task_id": "t" * 36, "title": "T"}), encoding="utf-8"
|
||||
)
|
||||
bundle.chmod(0o500)
|
||||
try:
|
||||
results = run_once(paths.data_root, Importer(FakeTodo(), OWNER), NOW)
|
||||
finally:
|
||||
bundle.chmod(0o700)
|
||||
assert results[0].outcome is Outcome.FAILED
|
||||
assert "marker not written" in results[0].detail
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user