mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 18:47:20 +01:00
Compare commits
4 Commits
focus-proj
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ae56037e41 | |||
| eef7af4c86 | |||
| 9daa376254 | |||
| 17e300af0a |
@ -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.1
|
||||
version: 0.2.2
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
dependencies:
|
||||
file_picker: 13.3.2
|
||||
file_picker: 13.3.3
|
||||
file_selector: 1.1.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
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
|
||||
@ -9,7 +9,7 @@ clipped at "now", so an event stamped ahead of the clock (a fast phone clock)
|
||||
earns only once the real clock reaches it. An unreadable registry reads as 0
|
||||
(logged): fail closed.
|
||||
|
||||
Unbacked minutes (0.8.0): timer time the anti-cheat gate refused
|
||||
Unbacked minutes (0.10.0): timer time the anti-cheat gate refused
|
||||
(:mod:`focus_project.sources.activity`) and no other source covers, on the
|
||||
local day up to "now"; carry and the cap play no part. Shown, never paid.
|
||||
"""
|
||||
|
||||
131
focus_project/focus_project/tests/test_backing_edges.py
Normal file
131
focus_project/focus_project/tests/test_backing_edges.py
Normal file
@ -0,0 +1,131 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""More 0.10.0 backing cases: sources, projects, the cutover midnight, chains.
|
||||
|
||||
The core rule lives in ``test_backing.py``; these pin the edges the spec
|
||||
named (C5-C7) with leetcode-ledger and other-project fixtures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
import focus_project as fp
|
||||
from focus_project import minutes
|
||||
from focus_project._paths import activity_path
|
||||
from focus_project.tests.test_leetcode_source import ac_row, write
|
||||
from focus_project.tests.test_minutes import at
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
DAY = date(2026, 11, 9) # after the cutover
|
||||
PRE = date(2026, 11, 7) # before it
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Nothing may fall back to the real home directory."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
||||
|
||||
|
||||
def make(pid: str = "hr", patterns: tuple[str, ...] = ("leetcode.com",)) -> None:
|
||||
fp.create_project(
|
||||
pid,
|
||||
name=pid,
|
||||
start=date(2026, 11, 6),
|
||||
end=date(2026, 11, 12),
|
||||
replaces=["leetcode"],
|
||||
daily_minutes=240,
|
||||
unlock_minutes=30,
|
||||
site_patterns=list(patterns),
|
||||
)
|
||||
|
||||
|
||||
def rows(*items: tuple[str, str, float] | tuple[str, str, float, str]) -> None:
|
||||
"""Append ``(source, kind, ts[, project])`` events (project default hr)."""
|
||||
activity_path().parent.mkdir(parents=True, exist_ok=True)
|
||||
with activity_path().open("a", encoding="utf-8") as handle:
|
||||
for item in items:
|
||||
source, kind, ts = item[:3]
|
||||
pid = item[3] if len(item) == 4 else "hr"
|
||||
row = {"ts": ts, "source": source, "kind": kind, "project": pid}
|
||||
handle.write(json.dumps(row) + "\n")
|
||||
|
||||
|
||||
def timer(day: date, *marks: tuple[int, int]) -> None:
|
||||
"""A segment: start at the first mark, check-ins, stop at the last."""
|
||||
kinds = ["start", *["checkin"] * (len(marks) - 2), "stop"]
|
||||
rows(
|
||||
*(
|
||||
("timer", k, at(h, m, day=day))
|
||||
for k, (h, m) in zip(kinds, marks, strict=True)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def beat(source: str, h: int, m: int, day: date = DAY, pid: str = "hr") -> None:
|
||||
rows((source, "heartbeat", at(h, m, day=day), pid))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["sql_drills", "leetcode"])
|
||||
def test_sql_drills_and_leetcode_heartbeats_back_too(source: str) -> None:
|
||||
make()
|
||||
timer(DAY, (9, 0), (9, 5), (9, 10))
|
||||
beat(source, 9, 5)
|
||||
assert minutes.minutes_for(DAY) == 10
|
||||
|
||||
|
||||
def test_browser_and_other_project_beats_do_not_back() -> None:
|
||||
make() # "other" needs no registry entry: rows are filtered by id
|
||||
timer(DAY, (9, 0), (9, 5), (9, 10))
|
||||
beat("browser", 9, 4)
|
||||
beat("tutor", 9, 4, pid="other")
|
||||
assert minutes.minutes_for(DAY) == 5 # the browser beat's own window only
|
||||
assert minutes.unbacked_minutes_for(DAY) == 6 # 9:04-9:10
|
||||
|
||||
|
||||
def test_accepted_submission_backs_a_leetcode_project(tmp_path: Path) -> None:
|
||||
make()
|
||||
timer(DAY, (9, 0), (9, 5), (9, 10))
|
||||
write(tmp_path, ac_row(at(9, 4, day=DAY)))
|
||||
assert minutes.minutes_for(DAY) == 11 # 8:59-9:10
|
||||
|
||||
|
||||
def test_submission_does_not_back_a_non_leetcode_project(tmp_path: Path) -> None:
|
||||
make("hr", ("sqlzoo.net",))
|
||||
timer(DAY, (9, 0), (9, 5), (9, 10))
|
||||
write(tmp_path, ac_row(at(9, 4, day=DAY)))
|
||||
assert minutes.minutes_for(DAY) == 0
|
||||
|
||||
|
||||
def test_span_across_the_cutover_midnight_follows_the_day() -> None:
|
||||
make()
|
||||
rows(
|
||||
("timer", "start", at(23, 58, day=date(2026, 11, 7))),
|
||||
("timer", "checkin", at(0, 3, day=date(2026, 11, 8))),
|
||||
("timer", "stop", at(0, 5, day=date(2026, 11, 8))),
|
||||
)
|
||||
assert minutes.minutes_for(date(2026, 11, 7)) == 2
|
||||
assert minutes.minutes_for(date(2026, 11, 8)) == 0
|
||||
assert minutes.unbacked_minutes_for(date(2026, 11, 8)) == 5
|
||||
|
||||
|
||||
def test_tutor_chained_span_is_backed_but_earlier_reading_is_not() -> None:
|
||||
"""A tutor beat chains the segment (0.8.0) and backs the span it ends.
|
||||
|
||||
start 9:00, checkin 9:08, tutor beat 9:16: the span 9:08-9:16 ends at a
|
||||
backing beat and earns; 9:00-9:08 has no beat in or 5 min before it and
|
||||
nothing bridges it, so it shows as unbacked.
|
||||
"""
|
||||
make()
|
||||
rows(
|
||||
("timer", "start", at(9, 0, day=DAY)),
|
||||
("timer", "checkin", at(9, 8, day=DAY)),
|
||||
)
|
||||
beat("tutor", 9, 16)
|
||||
assert minutes.minutes_for(DAY) == 8
|
||||
assert minutes.unbacked_minutes_for(DAY) == 8
|
||||
@ -93,6 +93,14 @@ web-relevant file is staged (`web.DEFAULT_FILES`, overridable with
|
||||
degrades to a pass, and the gate defers to that workflow when run inside
|
||||
another GitHub Actions job.
|
||||
|
||||
## Flutter apps: no Google non-free sign-in
|
||||
|
||||
A repo with a `pubspec.yaml` beside an `android/` dir (root or one level
|
||||
down) also gets `scripts/check_no_gms_signin.sh` (a shim) and the always-run
|
||||
`no-gms-signin` hook (`install_gms.py`). The shared gate fails on
|
||||
google_sign_in / play-services-auth / googleid in pubspec.lock or gradle, or
|
||||
in a built APK with `--apk`; apps use `google_account_token` instead.
|
||||
|
||||
## Flutter apps: the launcher rules
|
||||
|
||||
Decided by kuhy 2026-10-23 (`launcher.py`). They apply to every tracked
|
||||
|
||||
@ -25,6 +25,7 @@ from dep_freshness.install import NO_WORKFLOW_MARKER
|
||||
from repo_contract import bootstrap
|
||||
from repo_contract.check import violations
|
||||
from repo_contract.infer import propose
|
||||
from repo_contract.install_gms import install_gms, plan_gms
|
||||
from repo_contract.install_web import install_web, plan_web
|
||||
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
|
||||
from repo_contract.stacks import detect_stacks
|
||||
@ -64,6 +65,7 @@ def plan(repo: Path) -> list[str]:
|
||||
|
||||
A website (detected or declared in repo-contract.toml) also gets the
|
||||
AgentReady gate: config, shim, workflow and hook (see install_web.py).
|
||||
A Flutter Android app also gets the no-GMS-sign-in gate (install_gms.py).
|
||||
"""
|
||||
todo = []
|
||||
if _needs_shim(repo):
|
||||
@ -72,7 +74,7 @@ def plan(repo: Path) -> list[str]:
|
||||
todo.append(str(WORKFLOW))
|
||||
if needs_hook(repo, HOOK_ID):
|
||||
todo.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
|
||||
return todo + plan_web(repo)
|
||||
return todo + plan_web(repo) + plan_gms(repo)
|
||||
|
||||
|
||||
def install(repo: Path) -> list[str]:
|
||||
@ -92,7 +94,7 @@ def install(repo: Path) -> list[str]:
|
||||
if needs_hook(repo, HOOK_ID):
|
||||
write_hook(repo, _template("pre-commit-snippet.yaml"))
|
||||
done.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
|
||||
return done + install_web(repo)
|
||||
return done + install_web(repo) + install_gms(repo)
|
||||
|
||||
|
||||
def bootstrap_commands(repo: Path) -> list[str]:
|
||||
|
||||
62
repo_contract/install_gms.py
Normal file
62
repo_contract/install_gms.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""The no-GMS-sign-in half of the installer: Flutter Android apps get the gate.
|
||||
|
||||
Why it rides on install_repo_contract_gate.sh: that installer is the one step
|
||||
every new repo gets after `git init`, so a new Flutter app cannot come out of
|
||||
scaffolding able to re-add google_sign_in (13 apps were migrated off it onto
|
||||
utils/google_account_token in November 2026). Pieces: the
|
||||
scripts/check_no_gms_signin.sh delegate and an always-run pre-commit hook.
|
||||
Only repos with a pubspec.yaml next to an android/ dir (at the root or one
|
||||
level down, e.g. tutor/app) get it; utils itself holds the real gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
|
||||
|
||||
TEMPLATES = Path(__file__).resolve().parent / "templates"
|
||||
SHIM = Path("scripts/check_no_gms_signin.sh")
|
||||
HOOK_ID = "no-gms-signin"
|
||||
HOOK_PIECE = f"{PRECOMMIT} ({HOOK_ID} hook)"
|
||||
SHIM_TEMPLATE = "check_no_gms_signin.shim.sh"
|
||||
|
||||
|
||||
def _template(name: str) -> str:
|
||||
return (TEMPLATES / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def is_flutter_android(repo: Path) -> bool:
|
||||
"""A pubspec.yaml beside an android/ dir, at the root or one level down."""
|
||||
if (repo / "repo_contract" / "check.py").is_file():
|
||||
return False # utils: SHIM is the real gate, never a delegate
|
||||
pubspecs = [repo / "pubspec.yaml", *repo.glob("*/pubspec.yaml")]
|
||||
return any(p.is_file() and (p.parent / "android").is_dir() for p in pubspecs)
|
||||
|
||||
|
||||
def plan_gms(repo: Path) -> list[str]:
|
||||
"""Which no-GMS-sign-in gate pieces are missing or drifted."""
|
||||
if not is_flutter_android(repo):
|
||||
return []
|
||||
todo = []
|
||||
shim = repo / SHIM
|
||||
if not shim.is_file() or shim.read_text(encoding="utf-8") != _template(
|
||||
SHIM_TEMPLATE
|
||||
):
|
||||
todo.append(str(SHIM))
|
||||
if needs_hook(repo, HOOK_ID):
|
||||
todo.append(HOOK_PIECE)
|
||||
return todo
|
||||
|
||||
|
||||
def install_gms(repo: Path) -> list[str]:
|
||||
"""Write every missing or drifted no-GMS-sign-in piece. Returns them."""
|
||||
todo = plan_gms(repo)
|
||||
if str(SHIM) in todo:
|
||||
target = repo / SHIM
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(_template(SHIM_TEMPLATE), encoding="utf-8")
|
||||
target.chmod(0o755)
|
||||
if HOOK_PIECE in todo:
|
||||
write_hook(repo, _template("no-gms-signin-hook.yaml"))
|
||||
return todo
|
||||
27
repo_contract/templates/check_no_gms_signin.shim.sh
Normal file
27
repo_contract/templates/check_no_gms_signin.shim.sh
Normal file
@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Fail if this app links Google's non-free sign-in (google_sign_in,
|
||||
# play-services-auth, googleid) instead of google_account_token.
|
||||
#
|
||||
# Thin delegate to the shared gate in ~/src/utils, which owns the package
|
||||
# list and the fix message. Copy this file to scripts/check_no_gms_signin.sh
|
||||
# in a repo; never copy the shared gate itself.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/src/utils}/scripts/check_no_gms_signin.sh"
|
||||
|
||||
main() {
|
||||
if [[ ! -x "$SHARED_GATE" ]]; then
|
||||
echo "Error: shared no-gms-signin gate not found at $SHARED_GATE" >&2
|
||||
echo " Clone github.com/kuhyx/utils to ~/src/utils, or set" >&2
|
||||
echo " UTILS_ROOT to where it lives." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec bash "$SHARED_GATE" "$@"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
12
repo_contract/templates/no-gms-signin-hook.yaml
Normal file
12
repo_contract/templates/no-gms-signin-hook.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
# Added under `repos: - repo: local: hooks:` by install_repo_contract_gate.sh
|
||||
# for any repo with a pubspec.yaml beside an android/ dir (a Flutter app).
|
||||
# No Google non-free sign-in: google_sign_in links play-services-auth /
|
||||
# googleid into the APK; apps use utils/google_account_token instead.
|
||||
# always_run rather than `files:`: a poisoned pubspec.lock can arrive by
|
||||
# pull or merge, and a staged-only trigger would never look again.
|
||||
- id: no-gms-signin
|
||||
name: no google_sign_in / GMS auth (use google_account_token)
|
||||
entry: scripts/check_no_gms_signin.sh .
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
77
repo_contract/tests/test_install_gms.py
Normal file
77
repo_contract/tests/test_install_gms.py
Normal file
@ -0,0 +1,77 @@
|
||||
"""A Flutter repo with an Android target gets the no-GMS-sign-in gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import stat
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from repo_contract import install as inst
|
||||
from repo_contract import install_gms as gms
|
||||
|
||||
from .conftest import write
|
||||
|
||||
|
||||
def flutter_android(root: Path, sub: str = "") -> Path:
|
||||
"""A Flutter project at `root/sub` with an android/ dir."""
|
||||
base = f"{sub}/" if sub else ""
|
||||
write(root, f"{base}pubspec.yaml", "name: x\n")
|
||||
write(root, f"{base}android/app/build.gradle.kts", "plugins {}\n")
|
||||
return root
|
||||
|
||||
|
||||
def test_no_pubspec_means_no_gate(repo: Path) -> None:
|
||||
assert gms.plan_gms(repo) == []
|
||||
assert gms.install_gms(repo) == []
|
||||
|
||||
|
||||
def test_pubspec_without_android_means_no_gate(repo: Path) -> None:
|
||||
write(repo, "pubspec.yaml", "name: x\n")
|
||||
assert gms.plan_gms(repo) == []
|
||||
|
||||
|
||||
def test_root_flutter_android_gets_shim_and_hook(repo: Path) -> None:
|
||||
flutter_android(repo)
|
||||
assert gms.plan_gms(repo) == [str(gms.SHIM), gms.HOOK_PIECE]
|
||||
assert inst.install(repo)[-2:] == [str(gms.SHIM), gms.HOOK_PIECE]
|
||||
shim = repo / gms.SHIM
|
||||
assert shim.stat().st_mode & stat.S_IXUSR
|
||||
config = (repo / inst.PRECOMMIT).read_text(encoding="utf-8")
|
||||
assert f"id: {gms.HOOK_ID}" in config
|
||||
assert "always_run: true" in config
|
||||
assert gms.plan_gms(repo) == []
|
||||
assert gms.install_gms(repo) == []
|
||||
|
||||
|
||||
def test_nested_app_dir_counts(repo: Path) -> None:
|
||||
flutter_android(repo, "app")
|
||||
assert gms.plan_gms(repo) == [str(gms.SHIM), gms.HOOK_PIECE]
|
||||
|
||||
|
||||
def test_drifted_shim_is_rewritten_hook_kept(repo: Path) -> None:
|
||||
flutter_android(repo)
|
||||
gms.install_gms(repo)
|
||||
write(repo, gms.SHIM.as_posix(), "#!/bin/bash\nexit 0\n")
|
||||
assert gms.install_gms(repo) == [str(gms.SHIM)]
|
||||
|
||||
|
||||
def test_utils_itself_is_never_given_a_shim(repo: Path) -> None:
|
||||
flutter_android(repo, "google_account_token")
|
||||
write(repo, "repo_contract/check.py")
|
||||
assert gms.plan_gms(repo) == []
|
||||
|
||||
|
||||
def test_installed_shim_delegates_to_the_shared_gate(repo: Path) -> None:
|
||||
flutter_android(repo)
|
||||
write(repo, "pubspec.lock", "packages:\n google_sign_in_android:\n")
|
||||
gms.install_gms(repo)
|
||||
utils_root = Path(__file__).resolve().parents[2]
|
||||
proc = subprocess.run(
|
||||
["bash", str(repo / gms.SHIM), str(repo)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env={"UTILS_ROOT": str(utils_root), "PATH": "/usr/bin:/bin"},
|
||||
)
|
||||
assert proc.returncode == 1
|
||||
assert "google_account_token" in proc.stderr
|
||||
193
scripts/check_no_gms_signin.sh
Executable file
193
scripts/check_no_gms_signin.sh
Executable file
@ -0,0 +1,193 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Fail if a Flutter/Android app links Google's non-free sign-in stack.
|
||||
#
|
||||
# Every app signs in through kuhy's FOSS plugin google_account_token
|
||||
# (utils/google_account_token, Android AccountManager). google_sign_in pulls
|
||||
# google_sign_in_android, which links play-services-auth / googleid into the
|
||||
# APK. Nine apps were migrated off it on 2026-11-02 and four more on
|
||||
# 2026-11-10; nothing else stops a later session from re-adding it.
|
||||
#
|
||||
# Lockfile mode (default) reads pubspec.lock package keys, plus the gradle
|
||||
# files under each lock's sibling android/ dir. Web-only pieces
|
||||
# (google_sign_in_web, google_sign_in_platform_interface and their transitive
|
||||
# google_identity_services_web) are allowed while google_sign_in_android is
|
||||
# absent: kartka's web build uses them for the GIS button and they never
|
||||
# reach an APK. APK mode looks for the GMS auth library marker files in the
|
||||
# built APK, the ground truth when a dependency arrives some other way.
|
||||
#
|
||||
# Usage: check_no_gms_signin.sh [pubspec.lock | dir ...]
|
||||
# check_no_gms_signin.sh --apk app.apk
|
||||
# A dir is searched for pubspec.lock files (build/, .dart_tool/ pruned).
|
||||
# With no arguments, checks ./pubspec.lock (passes if there is none).
|
||||
# Exit: 0 clean | 1 non-free sign-in found | 2 usage error
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
readonly SCRIPT_NAME
|
||||
readonly BANNED=(google_sign_in google_sign_in_android google_sign_in_ios)
|
||||
readonly WEB_ONLY=(google_sign_in_web google_sign_in_platform_interface
|
||||
google_identity_services_web)
|
||||
# Maven coordinates (and split-form catalog names) of the GMS sign-in stack.
|
||||
# Anchored so com.google.gms:google-services (Firebase) and
|
||||
# play-services-auth-api-phone do not trip it.
|
||||
readonly GRADLE_PATTERN='com\.google\.android\.gms:play-services-(auth|identity-credentials)([^-[:alnum:]]|$)|com\.google\.android\.libraries\.identity\.googleid|androidx\.credentials:credentials-play-services-auth|name[[:space:]]*=[[:space:]]*"(play-services-auth|play-services-identity-credentials|credentials-play-services-auth|googleid)"'
|
||||
# APK mode: library marker files AGP copies to the APK root (or META-INF),
|
||||
# one per linked artifact. Dex strings are not used: play-services-base and
|
||||
# -basement reference GoogleSignInAccount without linking play-services-auth.
|
||||
# play-services-fido (passkeys) is deliberately allowed.
|
||||
readonly APK_MARKERS=(
|
||||
googleid.properties
|
||||
play-services-auth.properties
|
||||
play-services-auth-base.properties
|
||||
play-services-auth-api-phone.properties
|
||||
play-services-auth-blockstore.properties
|
||||
play-services-identity-credentials.properties
|
||||
META-INF/androidx.credentials_credentials-play-services-auth.version
|
||||
)
|
||||
readonly PRUNE=(-name build -o -name .dart_tool -o -name .git -o -name .gradle -o -name .claude
|
||||
-o -name node_modules)
|
||||
|
||||
usage() {
|
||||
echo "Usage: $SCRIPT_NAME [pubspec.lock | dir ...]"
|
||||
echo " $SCRIPT_NAME --apk app.apk"
|
||||
echo " Fails when an app links Google's non-free sign-in (GMS auth)."
|
||||
exit 0
|
||||
}
|
||||
|
||||
fix_hint() {
|
||||
echo " Use google_account_token instead (FOSS, AccountManager):" >&2
|
||||
echo " google_account_token:" >&2
|
||||
echo " git:" >&2
|
||||
echo " url: https://github.com/kuhyx/utils" >&2
|
||||
echo " ref: google_account_token-v0.1.0" >&2
|
||||
echo " path: google_account_token" >&2
|
||||
echo " See utils/google_account_token/README.md." >&2
|
||||
}
|
||||
|
||||
# Package keys of a pubspec.lock: the 2-space keys under `packages:`.
|
||||
lock_packages() {
|
||||
awk '
|
||||
/^packages:[[:space:]]*$/ { in_packages = 1; next }
|
||||
/^[^[:space:]]/ { in_packages = 0; next }
|
||||
in_packages && /^ [A-Za-z0-9_]+:[[:space:]]*$/ {
|
||||
sub(/^ /, ""); sub(/:.*/, ""); print
|
||||
}
|
||||
' "$1"
|
||||
}
|
||||
|
||||
# Prints the offending packages of one lock, one per line.
|
||||
lock_offenders() {
|
||||
local -A present=()
|
||||
local name
|
||||
while IFS= read -r name; do
|
||||
present["$name"]=1
|
||||
done < <(lock_packages "$1")
|
||||
for name in "${BANNED[@]}"; do
|
||||
[[ -n "${present[$name]:-}" ]] && echo "$name"
|
||||
done
|
||||
if [[ -n "${present[google_sign_in_android]:-}" ]]; then
|
||||
for name in "${WEB_ONLY[@]}"; do
|
||||
[[ -n "${present[$name]:-}" ]] && echo "$name"
|
||||
done
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Prints file:line:text for every non-comment GMS sign-in coordinate under
|
||||
# the lock's sibling android/ dir.
|
||||
gradle_offenders() {
|
||||
local android_dir="$1/android" file
|
||||
[[ -d "$android_dir" ]] || return 0
|
||||
while IFS= read -r -d '' file; do
|
||||
grep -nE "$GRADLE_PATTERN" "$file" \
|
||||
| grep -vE '^[0-9]+:[[:space:]]*(//|#|\*|/\*)' \
|
||||
| sed "s|^|$file:|" || true
|
||||
done < <(find "$android_dir" \( "${PRUNE[@]}" \) -prune -o -type f \
|
||||
\( -name 'build.gradle*' -o -name '*.versions.toml' \) -print0)
|
||||
}
|
||||
|
||||
indent() {
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
echo " $line"
|
||||
done
|
||||
}
|
||||
|
||||
check_lock() {
|
||||
local lock="$1" offenders gradle
|
||||
offenders="$(lock_offenders "$lock")"
|
||||
gradle="$(gradle_offenders "$(dirname "$lock")")"
|
||||
[[ -z "$offenders" && -z "$gradle" ]] && return 0
|
||||
if [[ -n "$offenders" ]]; then
|
||||
echo "Error: $lock links Google's non-free sign-in:" >&2
|
||||
indent <<<"$offenders" >&2
|
||||
fi
|
||||
if [[ -n "$gradle" ]]; then
|
||||
echo "Error: gradle names the GMS sign-in stack:" >&2
|
||||
indent <<<"$gradle" >&2
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
check_apk() {
|
||||
local apk="$1" entries found=()
|
||||
local marker
|
||||
[[ -f "$apk" ]] || { echo "Error: no such APK: $apk" >&2; exit 2; }
|
||||
entries="$(unzip -Z1 "$apk")"
|
||||
for marker in "${APK_MARKERS[@]}"; do
|
||||
grep -qxF -- "$marker" <<<"$entries" && found+=("$marker")
|
||||
done
|
||||
[[ "${#found[@]}" -eq 0 ]] && return 0
|
||||
echo "Error: $apk bundles Google's non-free sign-in libraries:" >&2
|
||||
printf ' %s\n' "${found[@]}" >&2
|
||||
fix_hint
|
||||
return 1
|
||||
}
|
||||
|
||||
# Expands the arguments into pubspec.lock paths, NUL-separated.
|
||||
collect_locks() {
|
||||
local arg
|
||||
for arg in "$@"; do
|
||||
if [[ -d "$arg" ]]; then
|
||||
find "$arg" \( "${PRUNE[@]}" \) -prune -o -type f \
|
||||
-name pubspec.lock -print0
|
||||
else
|
||||
printf '%s\0' "$arg"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
main() {
|
||||
case "${1:-}" in
|
||||
-h|--help) usage ;;
|
||||
--apk)
|
||||
[[ $# -eq 2 ]] || { echo "Usage: $SCRIPT_NAME --apk app.apk" >&2; exit 2; }
|
||||
check_apk "$2"
|
||||
return
|
||||
;;
|
||||
esac
|
||||
if [[ $# -eq 0 ]]; then
|
||||
[[ -f pubspec.lock ]] || return 0
|
||||
set -- pubspec.lock
|
||||
fi
|
||||
|
||||
local -a locks=()
|
||||
local arg lock failed=0
|
||||
# Validated up front: collect_locks runs in a process substitution, whose
|
||||
# exit status would be lost, and a typo'd path must not pass silently.
|
||||
for arg in "$@"; do
|
||||
[[ -e "$arg" ]] || { echo "Error: no such file or directory: $arg" >&2; exit 2; }
|
||||
done
|
||||
mapfile -d '' locks < <(collect_locks "$@")
|
||||
for lock in "${locks[@]}"; do
|
||||
check_lock "$lock" || failed=1
|
||||
done
|
||||
[[ "$failed" -eq 0 ]] || fix_hint
|
||||
return "$failed"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@ -9,6 +9,7 @@
|
||||
# when the repo has .dep-freshness-no-workflow), Commands section. A website
|
||||
# (Vite/Astro/index.html build, or [agent_ready] in repo-contract.toml) also
|
||||
# gets the AgentReady gate: config table, shim, hook, agent-ready workflow.
|
||||
# A Flutter app with android/ also gets the no-GMS-sign-in gate (shim + hook).
|
||||
#
|
||||
# Usage: scripts/install_repo_contract_gate.sh <repo> [--check] [--no-bootstrap]
|
||||
# Exit: 0 contract satisfied | 1 violations left (e.g. <FILL IN>) | 2 not a git repo
|
||||
|
||||
195
scripts/tests/test_check_no_gms_signin.py
Normal file
195
scripts/tests/test_check_no_gms_signin.py
Normal file
@ -0,0 +1,195 @@
|
||||
"""check_no_gms_signin.sh: no app may link Google's non-free sign-in stack.
|
||||
|
||||
Each test writes a throwaway Flutter-shaped project (pubspec.lock, optional
|
||||
android/ gradle files) or a fake APK built with zipfile, runs the real
|
||||
script, and asserts on the exit code and the message.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parent.parent / "check_no_gms_signin.sh"
|
||||
|
||||
# The package keys of kuhy's real kartka lock (web-only GIS button).
|
||||
KARTKA_KEYS = (
|
||||
"google_account_token",
|
||||
"google_identity_services_web",
|
||||
"google_sign_in_platform_interface",
|
||||
"google_sign_in_web",
|
||||
)
|
||||
|
||||
|
||||
def _lock(root: Path, *packages: str) -> Path:
|
||||
"""Write a pubspec.lock under `root` with the given package keys."""
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
body = ["# Generated by pub", "packages:"]
|
||||
for name in ("collection", *packages):
|
||||
body += [
|
||||
f" {name}:",
|
||||
' dependency: "direct main"',
|
||||
" description:",
|
||||
f" name: {name}",
|
||||
" source: hosted",
|
||||
' version: "1.0.0"',
|
||||
]
|
||||
body += ["sdks:", ' dart: ">=3.0.0 <4.0.0"', ""]
|
||||
lock = root / "pubspec.lock"
|
||||
lock.write_text("\n".join(body), encoding="utf-8")
|
||||
return lock
|
||||
|
||||
|
||||
def _run(*args: str | Path) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["bash", str(SCRIPT), *map(str, args)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def test_clean_lock_passes(tmp_path: Path) -> None:
|
||||
lock = _lock(tmp_path, "google_account_token", "http")
|
||||
assert _run(lock).returncode == 0
|
||||
|
||||
|
||||
def test_google_sign_in_android_fails_and_names_the_fix(tmp_path: Path) -> None:
|
||||
lock = _lock(tmp_path, "google_sign_in_android")
|
||||
proc = _run(lock)
|
||||
assert proc.returncode == 1
|
||||
assert str(lock) in proc.stderr
|
||||
assert "google_sign_in_android" in proc.stderr
|
||||
assert "google_account_token-v0.1.0" in proc.stderr
|
||||
assert "google_account_token/README.md" in proc.stderr
|
||||
|
||||
|
||||
def test_app_facing_google_sign_in_fails(tmp_path: Path) -> None:
|
||||
proc = _run(_lock(tmp_path, "google_sign_in"))
|
||||
assert proc.returncode == 1
|
||||
assert "google_sign_in " in proc.stderr or "google_sign_in\n" in proc.stderr
|
||||
|
||||
|
||||
def test_kartka_style_web_only_passes(tmp_path: Path) -> None:
|
||||
assert _run(_lock(tmp_path, *KARTKA_KEYS)).returncode == 0
|
||||
|
||||
|
||||
def test_web_pieces_fail_once_android_is_linked(tmp_path: Path) -> None:
|
||||
proc = _run(_lock(tmp_path, *KARTKA_KEYS, "google_sign_in_android"))
|
||||
assert proc.returncode == 1
|
||||
assert "google_sign_in_web" in proc.stderr
|
||||
|
||||
|
||||
def test_gradle_play_services_auth_fails(tmp_path: Path) -> None:
|
||||
lock = _lock(tmp_path)
|
||||
gradle = tmp_path / "android" / "app" / "build.gradle.kts"
|
||||
gradle.parent.mkdir(parents=True)
|
||||
gradle.write_text(
|
||||
'dependencies {\n implementation("com.google.gms:google-services:4")\n'
|
||||
' implementation("com.google.android.gms:play-services-auth:21.3.0")\n}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = _run(lock)
|
||||
assert proc.returncode == 1
|
||||
assert str(gradle) in proc.stderr
|
||||
assert "play-services-auth" in proc.stderr
|
||||
|
||||
|
||||
def test_versions_toml_googleid_fails(tmp_path: Path) -> None:
|
||||
lock = _lock(tmp_path)
|
||||
toml = tmp_path / "android" / "gradle" / "libs.versions.toml"
|
||||
toml.parent.mkdir(parents=True)
|
||||
toml.write_text(
|
||||
'googleid = "com.google.android.libraries.identity.googleid:googleid:1.1"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert _run(lock).returncode == 1
|
||||
|
||||
|
||||
def test_firebase_google_services_plugin_passes(tmp_path: Path) -> None:
|
||||
lock = _lock(tmp_path)
|
||||
gradle = tmp_path / "android" / "build.gradle"
|
||||
gradle.parent.mkdir(parents=True)
|
||||
gradle.write_text(
|
||||
"// play-services-auth was removed 2026-11-02\n"
|
||||
"classpath 'com.google.gms:google-services:4.4.2'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert _run(lock).returncode == 0
|
||||
|
||||
|
||||
def test_directory_mode_finds_nested_locks_and_prunes_build(tmp_path: Path) -> None:
|
||||
_lock(tmp_path / "app", "google_account_token")
|
||||
_lock(tmp_path / "app" / "build" / "x", "google_sign_in_android")
|
||||
_lock(tmp_path / ".dart_tool" / "y", "google_sign_in_android")
|
||||
# Agent worktrees are other checkouts, judged in their own repo.
|
||||
_lock(tmp_path / ".claude" / "worktrees" / "z", "google_sign_in_android")
|
||||
assert _run(tmp_path).returncode == 0
|
||||
_lock(tmp_path / "other", "google_sign_in_ios")
|
||||
proc = _run(tmp_path)
|
||||
assert proc.returncode == 1
|
||||
assert "google_sign_in_ios" in proc.stderr
|
||||
|
||||
|
||||
def test_missing_explicit_file_is_a_usage_error(tmp_path: Path) -> None:
|
||||
assert _run(tmp_path / "nope.lock").returncode == 2
|
||||
|
||||
|
||||
CREDENTIALS_MARKER = "META-INF/androidx.credentials_credentials-play-services-auth.version"
|
||||
DEX_REF = b"\x00Lcom/google/android/gms/auth/api/signin/GoogleSignInAccount;\x00" * 2
|
||||
|
||||
|
||||
def _apk(path: Path, dex: bytes = b"", markers: tuple[str, ...] = ()) -> Path:
|
||||
"""Fake APK: dex strings plus library marker files AGP copies in."""
|
||||
with zipfile.ZipFile(path, "w") as apk:
|
||||
apk.writestr("AndroidManifest.xml", b"\x03\x00")
|
||||
apk.writestr("classes.dex", b"dex\n035\x00" + dex)
|
||||
apk.writestr("classes2.dex", b"dex\n035\x00Lkotlin/Unit;\x00")
|
||||
for name in markers:
|
||||
apk.writestr(name, b"version=1")
|
||||
return path
|
||||
|
||||
|
||||
def test_apk_with_only_dex_references_passes(tmp_path: Path) -> None:
|
||||
"""play-services-base references GoogleSignInAccount without linking auth."""
|
||||
markers = (
|
||||
"play-services-base.properties",
|
||||
"play-services-basement.properties",
|
||||
"play-services-tasks.properties",
|
||||
"play-services-fido.properties",
|
||||
)
|
||||
proc = _run("--apk", _apk(tmp_path / "ok.apk", DEX_REF, markers))
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
|
||||
|
||||
def test_apk_without_markers_passes(tmp_path: Path) -> None:
|
||||
assert _run("--apk", _apk(tmp_path / "clean.apk")).returncode == 0
|
||||
|
||||
|
||||
def test_apk_banned_markers_fail_and_are_named(tmp_path: Path) -> None:
|
||||
banned = (
|
||||
"play-services-auth.properties",
|
||||
"googleid.properties",
|
||||
"play-services-auth-base.properties",
|
||||
"play-services-auth-api-phone.properties",
|
||||
"play-services-auth-blockstore.properties",
|
||||
"play-services-identity-credentials.properties",
|
||||
CREDENTIALS_MARKER,
|
||||
)
|
||||
for marker in banned:
|
||||
apk = _apk(tmp_path / "bad.apk", DEX_REF, ("play-services-base.properties", marker))
|
||||
proc = _run("--apk", apk)
|
||||
assert proc.returncode == 1, marker
|
||||
assert marker in proc.stderr
|
||||
assert "google_account_token" in proc.stderr
|
||||
|
||||
|
||||
def test_apk_nested_marker_name_is_not_a_root_marker(tmp_path: Path) -> None:
|
||||
"""Only APK-root files count; a same-named file in a subdir is not AGP's."""
|
||||
apk = _apk(tmp_path / "nested.apk", markers=("assets/play-services-auth.properties",))
|
||||
assert _run("--apk", apk).returncode == 0
|
||||
|
||||
|
||||
def test_apk_missing_file_is_a_usage_error(tmp_path: Path) -> None:
|
||||
assert _run("--apk", tmp_path / "nope.apk").returncode == 2
|
||||
@ -5,7 +5,7 @@ description: >-
|
||||
injected closures -- no keystore or platform plugin access lives in this
|
||||
package itself.
|
||||
publish_to: 'none'
|
||||
version: 0.6.2
|
||||
version: 0.6.3
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
@ -19,7 +19,7 @@ dependencies:
|
||||
data_transfer_flutter:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: data_transfer_flutter-v0.2.1
|
||||
ref: data_transfer_flutter-v0.2.2
|
||||
path: data_transfer_flutter
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
Loading…
Reference in New Issue
Block a user