workout poke moves to tcp/8774: 8773 is book-guard's desktop wrapper, whose window the poke server had been answering since 2026-10-09 (phone app rebuilt, selftest default, contract doc)
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
release-apk / release (APK) (push) Has been cancelled

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrBEzjPRdx5oyp8NtTLW5F
This commit is contained in:
Krzysztof Rudnicki 2026-10-10 09:38:21 +02:00
parent e1f1c668a8
commit b37b7559d8
9 changed files with 19 additions and 16 deletions

View File

@ -8,9 +8,10 @@ session that arrives both ways is credited once.
## Transport ## Transport
- `POST http://<pc-host>:8773/v1/workout`, `Content-Type: application/json`. - `POST http://<pc-host>:8774/v1/workout`, `Content-Type: application/json`.
(8773 until 2026-10-10: that is book-guard's desktop wrapper's port.)
- `<pc-host>` is a phone setting, default `192.168.1.43` (static on the PC). - `<pc-host>` is a phone setting, default `192.168.1.43` (static on the PC).
LAN only: the PC firewall accepts tcp/8773 from `192.168.1.0/24` and nothing LAN only: the PC firewall accepts tcp/8774 from `192.168.1.0/24` and nothing
else. No WireGuard. Off the home LAN the poke fails fast and the regular else. No WireGuard. Off the home LAN the poke fails fast and the regular
sync is the only path. sync is the only path.
- Header `X-Poke-Signature: <hex HMAC-SHA256(key, raw body bytes)>`. - Header `X-Poke-Signature: <hex HMAC-SHA256(key, raw body bytes)>`.

View File

@ -1,7 +1,7 @@
"""The resident LAN listener that credits a phone workout within a second. """The resident LAN listener that credits a phone workout within a second.
``workout-poke.service`` runs ``python3 -m screen_locker.poke_server``: a ``workout-poke.service`` runs ``python3 -m screen_locker.poke_server``: a
stdlib ``ThreadingHTTPServer`` on ``0.0.0.0:8773`` (the firewall admits only stdlib ``ThreadingHTTPServer`` on ``0.0.0.0:8774`` (the firewall admits only
192.168.1.0/24) answering ``POST /v1/workout`` per 192.168.1.0/24) answering ``POST /v1/workout`` per
``docs/DOCS-workout-poke-contract.md``. Every import -- the locker, the ``docs/DOCS-workout-poke-contract.md``. Every import -- the locker, the
earned-time registry, gatelock -- is paid once at startup, so a request costs earned-time registry, gatelock -- is paid once at startup, so a request costs

View File

@ -3,7 +3,7 @@
What ``workout-poke.service`` runs: load the shared key, warm the imports and What ``workout-poke.service`` runs: load the shared key, warm the imports and
the budget sum, start the Firebase session stream (off-LAN credit, the budget sum, start the Firebase session stream (off-LAN credit,
:mod:`screen_locker._session_stream`) on a daemon thread, then serve ``POST :mod:`screen_locker._session_stream`) on a daemon thread, then serve ``POST
/v1/workout`` on port 8773 for good. The request handling lives in /v1/workout`` on port 8774 for good. The request handling lives in
:mod:`screen_locker._poke_server`; the contract is :mod:`screen_locker._poke_server`; the contract is
``docs/DOCS-workout-poke-contract.md``. ``docs/DOCS-workout-poke-contract.md``.
@ -29,7 +29,8 @@ __all__ = ["DEFAULT_KEY_FILE", "DEFAULT_PORT", "load_key", "main"]
_logger: Final = logging.getLogger(__name__) _logger: Final = logging.getLogger(__name__)
DEFAULT_PORT: Final = 8773 DEFAULT_PORT: Final = 8774
"""Not 8773: that is book-guard's desktop wrapper (loopback, older)."""
DEFAULT_KEY_FILE: Final = Path.home() / ".config" / "workout_poke" / "key" DEFAULT_KEY_FILE: Final = Path.home() / ".config" / "workout_poke" / "key"
_KEYGEN_HINT: Final = "run scripts/workout_poke_keygen.sh" _KEYGEN_HINT: Final = "run scripts/workout_poke_keygen.sh"
@ -50,7 +51,7 @@ def load_key(path: Path) -> bytes:
def _parse_args(argv: list[str] | None) -> argparse.Namespace: def _parse_args(argv: list[str] | None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
# "" binds every interface; the firewall admits tcp/8773 from the LAN only. # "" binds every interface; the firewall admits tcp/8774 from the LAN only.
parser.add_argument("--bind", default="", help="address (default: all)") parser.add_argument("--bind", default="", help="address (default: all)")
parser.add_argument("--port", type=int, default=DEFAULT_PORT) parser.add_argument("--port", type=int, default=DEFAULT_PORT)
parser.add_argument("--key-file", type=Path, default=DEFAULT_KEY_FILE) parser.add_argument("--key-file", type=Path, default=DEFAULT_KEY_FILE)

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Drive the running workout poke listener from this PC, signed with the real key. """Drive the running workout poke listener from this PC, signed with the real key.
Each case POSTs to ``http://127.0.0.1:8773/v1/workout`` exactly as the phone Each case POSTs to ``http://127.0.0.1:8774/v1/workout`` exactly as the phone
would (``docs/DOCS-workout-poke-contract.md``) and prints the status and reply: would (``docs/DOCS-workout-poke-contract.md``) and prints the status and reply:
* ``sandbox`` -- a signed sandbox request: 200, ok, credited=false; * ``sandbox`` -- a signed sandbox request: 200, ok, credited=false;
@ -109,7 +109,7 @@ def main() -> int:
"case", choices=["sandbox", "badsig", "replay", "stale", "duplicate", "latency"] "case", choices=["sandbox", "badsig", "replay", "stale", "duplicate", "latency"]
) )
parser.add_argument("--count", type=int, default=20) parser.add_argument("--count", type=int, default=20)
parser.add_argument("--port", type=int, default=8773) parser.add_argument("--port", type=int, default=8774)
args = parser.parse_args() args = parser.parse_args()
key = bytes.fromhex(KEY_FILE.read_text(encoding="ascii").strip()) key = bytes.fromhex(KEY_FILE.read_text(encoding="ascii").strip())
if args.case == "sandbox": if args.case == "sandbox":

View File

@ -15,7 +15,7 @@ import 'package:workout_app/services/pc_poke_wire.dart';
/// guard per service. A refused connection surfaces as a normal sync failure /// guard per service. A refused connection surfaces as a normal sync failure
/// in the UI, which is the honest answer: the sandbox is offline by design. /// in the UI, which is the honest answer: the sandbox is offline by design.
/// ///
/// The single exception is `http://<pokeHost>:8773/v1/workout`, so a sandbox /// The single exception is `http://<pokeHost>:8774/v1/workout`, so a sandbox
/// workout can time the PC round trip. The PC verifies the signed /// workout can time the PC round trip. The PC verifies the signed
/// `"sandbox": true` and never writes or credits it. Firebase and GitHub are /// `"sandbox": true` and never writes or credits it. Firebase and GitHub are
/// https on 443 and can never match. /// https on 443 and can never match.

View File

@ -19,7 +19,7 @@ import 'package:workout_app/services/workout_sync_service.dart';
export 'package:workout_app/services/pc_poke_result.dart'; export 'package:workout_app/services/pc_poke_result.dart';
/// Signs and POSTs one session to `http://<pc-host>:8773/v1/workout`. /// Signs and POSTs one session to `http://<pc-host>:8774/v1/workout`.
/// ///
/// Runs beside [WorkoutSyncService.push], never instead of it: the PC dedups /// Runs beside [WorkoutSyncService.push], never instead of it: the PC dedups
/// by record id, so whichever path lands first credits and the other is a /// by record id, so whichever path lands first credits and the other is a

View File

@ -11,8 +11,9 @@ import 'dart:typed_data';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
/// TCP port the PC's poke listener binds (8771 belongs to another service). /// TCP port the PC's poke listener binds (8771 and 8773 -- book-guard's
const int kPcPokePort = 8773; /// desktop wrapper -- belong to other services).
const int kPcPokePort = 8774;
/// Request path on the PC. /// Request path on the PC.
const String kPcPokePath = '/v1/workout'; const String kPcPokePath = '/v1/workout';

View File

@ -36,7 +36,7 @@ void main() {
expect(SandboxHttpOverrides.allows(poke), isFalse); expect(SandboxHttpOverrides.allows(poke), isFalse);
}); });
test('allows exactly http + configured host + 8773 + /v1/workout', () { test('allows exactly http + configured host + 8774 + /v1/workout', () {
SandboxHttpOverrides.pokeHost = host; SandboxHttpOverrides.pokeHost = host;
expect(SandboxHttpOverrides.allows(poke), isTrue); expect(SandboxHttpOverrides.allows(poke), isTrue);
for (final other in [ for (final other in [
@ -87,12 +87,12 @@ void main() {
} on SocketException catch (error) { } on SocketException catch (error) {
outcome = error; outcome = error;
} }
// No listener on this host's 8773 is fine -- what matters is that a // No listener on this host's poke port is fine -- what matters is that a
// refusal, if any, did not come from the sandbox. // refusal, if any, did not come from the sandbox.
if (outcome is SocketException) { if (outcome is SocketException) {
expect(outcome.message, isNot(contains('sandbox'))); expect(outcome.message, isNot(contains('sandbox')));
} }
expect(printed.first, contains('allowing the PC poke to $host:8773')); expect(printed.first, contains('allowing the PC poke to $host:$kPcPokePort'));
client.close(force: true); client.close(force: true);
}); });
}); });

View File

@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Credit a phone workout the second it is poked over the LAN (tcp/8773) Description=Credit a phone workout the second it is poked over the LAN (tcp/8774)
[Service] [Service]
# Resident stdlib HTTP server (screen_locker/_poke_server.py): the phone POSTs # Resident stdlib HTTP server (screen_locker/_poke_server.py): the phone POSTs