cookiesync-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ """Schema-version-aware async I/O against Chrome's SQLite cookie store.
2
+
3
+ Reads copy the live ``Cookies`` DB (plus its ``-wal``/``-shm``/``-journal`` sidecars)
4
+ into a private temp dir and open the copy read-write, so a running browser is never
5
+ disturbed and the WAL checkpoints into the copy before we ``SELECT``. Writes go to the
6
+ live DB, best-effort: a short ``busy_timeout`` plus a soft-busy return on a locked DB,
7
+ never a forced clobber.
8
+
9
+ Chrome's cookie schema drifts across versions: v18 carries ``is_same_party`` and a
10
+ ``UNIQUE(host_key, top_frame_site_key, name, path)`` index, while v24 drops
11
+ ``is_same_party``, adds ``source_type`` + ``has_cross_site_ancestor``, and widens the
12
+ unique index to include ``has_cross_site_ancestor``, ``source_scheme``, and
13
+ ``source_port``. Every operation introspects the actual table and unique index via
14
+ ``PRAGMA table_info`` / ``PRAGMA index_list`` / ``PRAGMA index_info`` rather than
15
+ hardcoding one column set.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import shutil
22
+ import tempfile
23
+ import time
24
+ from pathlib import Path
25
+ from typing import TYPE_CHECKING
26
+
27
+ import aiosqlite
28
+
29
+ from cookiesync.cookie.crypto import encrypt_value
30
+ from cookiesync.cookie.domains import cookie_applies
31
+ from cookiesync.cookie.models import (
32
+ ChromeMicros,
33
+ EncryptedRow,
34
+ Host,
35
+ HostKey,
36
+ unix_to_chrome_micros,
37
+ )
38
+
39
+ if TYPE_CHECKING:
40
+ from collections.abc import Sequence
41
+
42
+ from cookiesync.cookie.browsers import Browser
43
+ from cookiesync.cookie.models import AesKey, Cookie
44
+
45
+ SIDECAR_SUFFIXES = ("-wal", "-shm", "-journal")
46
+
47
+ BUSY_TIMEOUT_MS = 250
48
+
49
+ ROW_FIELD_DEFAULTS: dict[str, object] = {
50
+ "encrypted_value": b"",
51
+ "value": "",
52
+ "source_scheme": 2,
53
+ "source_port": 443,
54
+ "top_frame_site_key": "",
55
+ "has_cross_site_ancestor": 0,
56
+ }
57
+
58
+
59
+ def _to_micros(value: int) -> ChromeMicros:
60
+ return ChromeMicros(int(value))
61
+
62
+
63
+ def _row_from_columns(columns: tuple[str, ...], values: tuple[object, ...]) -> EncryptedRow:
64
+ cells = ROW_FIELD_DEFAULTS | dict(zip(columns, values, strict=True))
65
+ return EncryptedRow(
66
+ host_key=HostKey(cells["host_key"]),
67
+ name=cells["name"],
68
+ encrypted_value=bytes(ev) if (ev := cells["encrypted_value"]) is not None else b"",
69
+ value=cells["value"] or "",
70
+ path=cells["path"],
71
+ expires_utc=_to_micros(cells["expires_utc"]),
72
+ last_update_utc=_to_micros(cells.get("last_update_utc", 0)),
73
+ creation_utc=_to_micros(cells["creation_utc"]),
74
+ is_secure=bool(cells["is_secure"]),
75
+ is_httponly=bool(cells["is_httponly"]),
76
+ samesite=int(cells["samesite"]),
77
+ source_scheme=int(cells["source_scheme"]),
78
+ source_port=int(cells["source_port"]),
79
+ top_frame_site_key=str(cells["top_frame_site_key"]),
80
+ has_cross_site_ancestor=int(cells["has_cross_site_ancestor"]),
81
+ )
82
+
83
+
84
+ async def _table_columns(db: aiosqlite.Connection) -> tuple[str, ...]:
85
+ async with db.execute("PRAGMA table_info(cookies)") as cur:
86
+ return tuple(row[1] for row in await cur.fetchall())
87
+
88
+
89
+ async def _unique_index_columns(db: aiosqlite.Connection) -> tuple[str, ...]:
90
+ async with db.execute("PRAGMA index_list(cookies)") as cur:
91
+ indexes = await cur.fetchall()
92
+ name = next(idx[1] for idx in indexes if idx[2] and not idx[4])
93
+ async with db.execute(f"PRAGMA index_info({name})") as cur:
94
+ return tuple(col[2] for col in await cur.fetchall())
95
+
96
+
97
+ def _copy_with_sidecars(db: Path, dest_dir: Path) -> Path:
98
+ copy = dest_dir / "Cookies"
99
+ shutil.copy2(db, copy)
100
+ for suffix in SIDECAR_SUFFIXES:
101
+ if (side := Path(f"{db}{suffix}")).is_file():
102
+ shutil.copy2(side, f"{copy}{suffix}")
103
+ return copy
104
+
105
+
106
+ async def read_rows(browser: Browser, profile: str) -> tuple[EncryptedRow, ...]:
107
+ """Every cookie row in ``profile`` as raw ``EncryptedRow``s, read off a private copy.
108
+
109
+ The live ``Cookies`` DB and its WAL/journal sidecars are copied to a temp dir and the
110
+ copy is opened read-write so SQLite checkpoints the WAL before the read. Only columns
111
+ present in this store's schema are selected; absent columns fall back to their defaults.
112
+ """
113
+ tmpdir = Path(tempfile.mkdtemp(prefix="cookiesync-"))
114
+ try:
115
+ copy = _copy_with_sidecars(browser.cookies_db(profile), tmpdir)
116
+ async with aiosqlite.connect(copy) as db:
117
+ columns = await _table_columns(db)
118
+ async with db.execute(f"SELECT {', '.join(columns)} FROM cookies") as cur:
119
+ rows = await cur.fetchall()
120
+ return tuple(_row_from_columns(columns, tuple(values)) for values in rows)
121
+ finally:
122
+ shutil.rmtree(tmpdir, ignore_errors=True)
123
+
124
+
125
+ async def list_profile_dirs(browser: Browser) -> tuple[str, ...]:
126
+ """Profile directory names under ``browser`` that hold a ``Cookies`` DB, sorted."""
127
+ root = browser.data_root
128
+ return tuple(sorted(c.name for c in root.iterdir() if c.is_dir() and browser.cookies_db(c.name).is_file()))
129
+
130
+
131
+ async def count_applicable(browser: Browser, profile: str, host: Host) -> int:
132
+ """How many cookies in ``profile`` a browser would send to ``host`` (no decryption)."""
133
+ return sum(cookie_applies(row.host_key, host) for row in await read_rows(browser, profile))
134
+
135
+
136
+ async def profile_info(browser: Browser) -> dict[str, dict[str, str]]:
137
+ """Map each profile directory name to its ``{email, name}`` from ``Local State``.
138
+
139
+ Read from the browser's ``Local State`` JSON under ``profile.info_cache``: ``email``
140
+ comes from ``user_name`` and ``name`` from ``gaia_name`` (falling back to ``name``).
141
+ """
142
+ cache = json.loads(browser.local_state().read_text()).get("profile", {}).get("info_cache", {})
143
+ return {
144
+ name: {
145
+ "email": info.get("user_name", ""),
146
+ "name": info.get("gaia_name", "") or info.get("name", ""),
147
+ }
148
+ for name, info in cache.items()
149
+ }
150
+
151
+
152
+ def _insert_values(cookie: Cookie, encrypted: bytes) -> dict[str, object]:
153
+ creation = cookie.creation_utc if cookie.creation_utc > 0 else unix_to_chrome_micros(time.time())
154
+ has_expires = int(cookie.expires_utc > 0)
155
+ return {
156
+ "creation_utc": int(creation),
157
+ "host_key": cookie.host_key,
158
+ "top_frame_site_key": cookie.top_frame_site_key,
159
+ "name": cookie.name,
160
+ "value": "",
161
+ "encrypted_value": encrypted,
162
+ "path": cookie.path,
163
+ "expires_utc": int(cookie.expires_utc),
164
+ "is_secure": int(cookie.is_secure),
165
+ "is_httponly": int(cookie.is_httponly),
166
+ "last_access_utc": int(cookie.last_update_utc),
167
+ "has_expires": has_expires,
168
+ "is_persistent": has_expires,
169
+ "priority": 1,
170
+ "samesite": cookie.samesite,
171
+ "source_scheme": cookie.source_scheme,
172
+ "source_port": cookie.source_port,
173
+ "last_update_utc": int(cookie.last_update_utc),
174
+ "source_type": 0,
175
+ "has_cross_site_ancestor": cookie.has_cross_site_ancestor,
176
+ "is_same_party": 0,
177
+ }
178
+
179
+
180
+ def _upsert_sql(columns: tuple[str, ...], conflict: tuple[str, ...]) -> str:
181
+ cols = ", ".join(columns)
182
+ placeholders = ", ".join(f":{c}" for c in columns)
183
+ updates = ", ".join(
184
+ f"{c} = excluded.{c}"
185
+ for c in ("encrypted_value", "value", "expires_utc", "last_update_utc", "is_secure", "is_httponly", "samesite")
186
+ if c in columns
187
+ )
188
+ return (
189
+ f"INSERT INTO cookies ({cols}) VALUES ({placeholders}) "
190
+ f"ON CONFLICT({', '.join(conflict)}) DO UPDATE SET {updates}"
191
+ )
192
+
193
+
194
+ async def write_rows(browser: Browser, profile: str, cookies: Sequence[Cookie], key: AesKey) -> int:
195
+ """Encrypt and upsert ``cookies`` into ``profile``'s live ``Cookies`` DB; return rows written.
196
+
197
+ Each value is re-encrypted into a ``v10`` blob (the plaintext ``value`` column is left
198
+ empty) and written with ``INSERT ... ON CONFLICT(<this store's real unique index>) DO
199
+ UPDATE``, so a re-synced cookie collapses onto its existing row. The cookie's own
200
+ ``last_update_utc`` and ``creation_utc`` are preserved, never stamped to "now". On a
201
+ locked database this returns ``-1`` (soft busy) rather than forcing a write.
202
+ """
203
+ db_path = browser.cookies_db(profile)
204
+ async with aiosqlite.connect(db_path) as db:
205
+ await db.execute(f"PRAGMA busy_timeout = {BUSY_TIMEOUT_MS}")
206
+ columns = await _table_columns(db)
207
+ conflict = await _unique_index_columns(db)
208
+ sql = _upsert_sql(columns, conflict)
209
+ try:
210
+ for cookie in cookies:
211
+ values = _insert_values(cookie, encrypt_value(cookie.value, key, cookie.host_key))
212
+ await db.execute(sql, {c: values[c] for c in columns})
213
+ await db.commit()
214
+ except aiosqlite.OperationalError as exc:
215
+ if "locked" not in str(exc):
216
+ raise
217
+ return -1
218
+ return len(cookies)
@@ -0,0 +1,13 @@
1
+ """The cookiesync sync daemon: active-session detection, the watch/sync loops, and the unix-socket RPC.
2
+
3
+ Public surface re-exported here is what the CLI and RPC layers call; everything else
4
+ in the package stays internal.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from cookiesync.daemon.rpc import Dispatcher, RpcError, call, serve
10
+ from cookiesync.daemon.server import AuthRequired, CachedKeySource, Daemon
11
+ from cookiesync.daemon.session import has_active_session, session_summary
12
+ from cookiesync.daemon.sync import NeedsAuth, converge, reconcile
13
+ from cookiesync.daemon.wire import Request, Response, cookie_from_wire, cookie_to_wire
@@ -0,0 +1,70 @@
1
+ """The ssh-backed peer source: drive a remote host's daemon to read and write its cookies.
2
+
3
+ A peer's cookies never leave its machine encrypted — decryption happens *remotely*, in the
4
+ remote daemon's live GUI session, so the Safe Storage key never crosses the wire.
5
+ ``SshBackend`` is the client side of that exchange and the peer half of the sync
6
+ :class:`~cookiesync.daemon.sync.Source` seam: ``extract`` shells out to ``cookiesync rpc
7
+ extract`` on the peer and parses the wire cookie records it streams back; ``apply`` pipes
8
+ the merged wire payload to ``cookiesync rpc apply`` on the peer's stdin. The remote ``rpc``
9
+ command is wired by the integrator; this module owns the client and the payload shape.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING
17
+
18
+ from cookiesync.daemon.sync import Extracted
19
+ from cookiesync.daemon.wire import cookie_from_wire, cookie_to_wire
20
+ from cookiesync.transport import shell_quote, ssh
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Sequence
24
+
25
+ from cookiesync.cookie.models import Cookie
26
+ from cookiesync.state import BrowserId, SshTarget
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class SshBackend:
31
+ """A peer host's cookie store, reached by driving its daemon over ssh.
32
+
33
+ The peer decrypts in its own GUI session, so cookies cross the wire already decrypted
34
+ and the peer's Safe Storage key never leaves its machine. ``origin`` is this host's own
35
+ target, forwarded on every call so the peer's daemon can suppress the echo back to us.
36
+
37
+ Example:
38
+ >>> SshBackend(SshTarget("me@laptop"), origin=self_target)
39
+ """
40
+
41
+ target: SshTarget
42
+ origin: SshTarget
43
+
44
+ async def extract(self, browser: BrowserId, profile: str) -> Extracted:
45
+ """Extract the peer's decrypted cookies for ``browser``/``profile``."""
46
+ payload = json.loads(
47
+ await ssh(
48
+ self.target,
49
+ "cookiesync rpc extract"
50
+ f" --browser {shell_quote(browser)} --profile {shell_quote(profile)}"
51
+ f" --origin {shell_quote(self.origin)}",
52
+ )
53
+ )
54
+ return Extracted(tuple(cookie_from_wire(c) for c in payload["cookies"]))
55
+
56
+ async def apply(self, browser: BrowserId, profile: str, cookies: Sequence[Cookie]) -> int:
57
+ """Apply the merged ``cookies`` to the peer's ``browser``/``profile`` store, returning rows written.
58
+
59
+ The merged set is piped as a JSON array of wire records to the peer's
60
+ ``cookiesync rpc apply`` stdin; the peer re-encrypts with its own key.
61
+ """
62
+ return json.loads(
63
+ await ssh(
64
+ self.target,
65
+ "cookiesync rpc apply"
66
+ f" --browser {shell_quote(browser)} --profile {shell_quote(profile)}"
67
+ f" --origin {shell_quote(self.origin)}",
68
+ stdin=json.dumps([cookie_to_wire(c) for c in cookies]).encode(),
69
+ )
70
+ )["applied"]
@@ -0,0 +1,113 @@
1
+ """Short-TTL, Secure-Enclave-wrapped cache for derived AES keys.
2
+
3
+ The sync daemon derives a browser's Safe Storage key behind one Touch ID tap, then
4
+ reuses it for a brief window so a burst of operations needs only a single prompt. The
5
+ plaintext key lives in process memory for that window, but the AT-REST cache bytes are
6
+ Secure-Enclave-wrapped: a leaked cache blob or a core dump is useless off-box, since
7
+ only the live per-boot Enclave key can unwrap it.
8
+
9
+ :class:`SecureEnclaveWrapper` drives the installed, Developer-ID-signed
10
+ ``cookiesync-keyhelper.app`` (``cache-newkey`` / ``cache-wrap`` / ``cache-unwrap`` /
11
+ ``cache-dropkey``). An ad-hoc helper is refused the Enclave, so a missing helper fails
12
+ closed — see :func:`cookiesync.paths.require_helper`. Tests inject a :class:`Wrapper`
13
+ double and a clock, so the cache logic is exercised without any macOS API.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import secrets
19
+ import time
20
+ from collections.abc import Callable
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Protocol
24
+
25
+ import anyio
26
+
27
+ from cookiesync import paths
28
+
29
+
30
+ class Wrapper(Protocol):
31
+ """Wraps and unwraps key bytes so the at-rest cache value is opaque off-box."""
32
+
33
+ async def wrap(self, plaintext: bytes) -> bytes: ...
34
+
35
+ async def unwrap(self, blob: bytes) -> bytes: ...
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class SecureEnclaveWrapper:
40
+ """Wraps key bytes against a per-boot ephemeral Secure-Enclave P-256 key.
41
+
42
+ The Enclave key is created in :meth:`open` (one random label per process) and
43
+ destroyed in :meth:`close`, so wrapped blobs are unrecoverable after the daemon
44
+ exits or the machine reboots.
45
+
46
+ Example:
47
+ >>> wrapper = await SecureEnclaveWrapper.open()
48
+ >>> blob = await wrapper.wrap(key)
49
+ >>> assert await wrapper.unwrap(blob) == key
50
+ >>> await wrapper.close()
51
+ """
52
+
53
+ helper: Path
54
+ label: str = field(default_factory=lambda: secrets.token_hex(8))
55
+
56
+ @classmethod
57
+ async def open(cls) -> SecureEnclaveWrapper:
58
+ wrapper = cls(paths.require_helper())
59
+ await anyio.run_process([str(wrapper.helper), "cache-newkey", wrapper.label])
60
+ return wrapper
61
+
62
+ async def wrap(self, plaintext: bytes) -> bytes:
63
+ return (await anyio.run_process([str(self.helper), "cache-wrap", self.label], input=plaintext)).stdout
64
+
65
+ async def unwrap(self, blob: bytes) -> bytes:
66
+ return (await anyio.run_process([str(self.helper), "cache-unwrap", self.label], input=blob)).stdout
67
+
68
+ async def close(self) -> None:
69
+ await anyio.run_process([str(self.helper), "cache-dropkey", self.label])
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class Entry:
74
+ blob: bytes
75
+ expires_at: float
76
+
77
+
78
+ @dataclass(slots=True)
79
+ class KeyCache:
80
+ """A short-TTL cache of derived AES keys, each stored only as a wrapped blob.
81
+
82
+ ``put`` wraps a key and records its expiry; ``get`` unwraps transiently and returns
83
+ ``None`` once the entry is expired or evicted. The plaintext is never persisted to
84
+ disk and never logged. The clock is injectable for tests.
85
+
86
+ Example:
87
+ >>> cache = KeyCache(await SecureEnclaveWrapper.open())
88
+ >>> await cache.put(endpoint.id, key, ttl=30.0)
89
+ >>> assert await cache.get(endpoint.id) == key
90
+ """
91
+
92
+ wrapper: Wrapper
93
+ now: Callable[[], float] = time.monotonic
94
+ entries: dict[str, Entry] = field(default_factory=dict)
95
+
96
+ async def put(self, endpoint_id: str, key: bytes, ttl: float) -> None:
97
+ self.entries[endpoint_id] = Entry(await self.wrapper.wrap(key), self.now() + ttl)
98
+
99
+ async def get(self, endpoint_id: str) -> bytes | None:
100
+ match self.entries.get(endpoint_id):
101
+ case None:
102
+ return None
103
+ case Entry(expires_at=expires_at) if self.now() >= expires_at:
104
+ del self.entries[endpoint_id]
105
+ return None
106
+ case Entry(blob=blob):
107
+ return await self.wrapper.unwrap(blob)
108
+
109
+ def evict(self, endpoint_id: str) -> None:
110
+ self.entries.pop(endpoint_id, None)
111
+
112
+ def evict_all(self) -> None:
113
+ self.entries.clear()
@@ -0,0 +1,195 @@
1
+ """The watch engine: debounce a cookie-store write burst into one converge, with anti-echo.
2
+
3
+ A Chrome/Arc cookie write lands as a burst across the ``Cookies`` DB and its ``-wal``/
4
+ ``-shm`` sidecars. The engine coalesces that burst, per endpoint, into a single
5
+ ``evaluate`` once the store has been quiet for ``settings.watch_debounce``, then fires the
6
+ injected ``notify`` callback so the sync layer converges that endpoint with its peers.
7
+
8
+ Two filters keep the engine from chasing its own tail:
9
+
10
+ * **Anti-echo.** ``evaluate`` fingerprints the endpoint's *logical* cookie set via
11
+ :func:`logical_digest` — sorted ``(host_key, name, path, last_update_utc)`` tuples, cheap
12
+ and decryption-free — and compares it to the last digest the engine acted on. The sync
13
+ layer records the digest of the very set it is about to write via :meth:`record_applied`
14
+ just before writing; because the store preserves each cookie's ``last_update_utc``, the
15
+ self-induced write reproduces that same digest and is recognized as a no-op. Only a
16
+ genuinely new digest is recorded (*before* notifying) and notified.
17
+ * **Idle gate.** A store whose ``Cookies`` wall-clock mtime is within
18
+ ``settings.idle_threshold`` is a browser actively writing; the engine debounces but does
19
+ not notify until it settles.
20
+
21
+ The watcher source, clocks, mtime reader, digest function, and notify callback are all
22
+ injected, so the debounce/anti-echo/idle logic runs in unit tests with no real filesystem,
23
+ browser, or macOS API.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import time
30
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
31
+ from dataclasses import dataclass, field
32
+ from typing import TYPE_CHECKING, NewType, Protocol
33
+
34
+ import anyio
35
+
36
+ from cookiesync.cookie import REGISTRY
37
+ from cookiesync.cookie.browsers import BrowserName
38
+ from cookiesync.cookie.stores import read_rows
39
+
40
+ if TYPE_CHECKING:
41
+ from cookiesync.cookie.browsers import Browser
42
+ from cookiesync.state import BrowserEndpoint, Settings
43
+
44
+ Digest = NewType("Digest", str)
45
+
46
+ type AwatchSource = Callable[[BrowserEndpoint], AsyncIterator[object]]
47
+ type Notify = Callable[[BrowserEndpoint], Awaitable[None]]
48
+ type DigestFn = Callable[[BrowserEndpoint], Awaitable[Digest]]
49
+ type Mtime = Callable[[BrowserEndpoint], Awaitable[float]]
50
+ type Sleep = Callable[[float], Awaitable[None]]
51
+
52
+
53
+ class LogicalRow(Protocol):
54
+ """The fields :func:`logical_digest` keys on; satisfied by both ``Cookie`` and ``EncryptedRow``."""
55
+
56
+ host_key: str
57
+ name: str
58
+ path: str
59
+ last_update_utc: int
60
+
61
+
62
+ def logical_digest(items: Iterable[LogicalRow]) -> Digest:
63
+ """A cheap, decryption-free digest of a cookie set's logical identity.
64
+
65
+ Hashes the sorted ``(host_key, name, path, last_update_utc)`` tuples of every item. It
66
+ ignores encrypted values and schema-only columns, so it changes exactly when the logical
67
+ cookie set does. The sync layer digests the set it is about to write and the engine
68
+ fingerprints the store after the write; because ``last_update_utc`` is preserved on write,
69
+ the two agree and the induced filesystem event is recognized as the engine's own echo.
70
+ """
71
+ payload = "\x00".join(
72
+ f"{item.host_key}\x1f{item.name}\x1f{item.path}\x1f{item.last_update_utc}"
73
+ for item in sorted(items, key=lambda i: (i.host_key, i.name, i.path, i.last_update_utc))
74
+ )
75
+ return Digest(hashlib.sha256(payload.encode("utf-8")).hexdigest())
76
+
77
+
78
+ def endpoint_browser(endpoint: BrowserEndpoint) -> Browser:
79
+ """The :class:`~cookiesync.cookie.browsers.Browser` an endpoint names."""
80
+ return REGISTRY[BrowserName(endpoint.browser)]
81
+
82
+
83
+ def watch_dir(endpoint: BrowserEndpoint) -> object:
84
+ """The directory holding an endpoint's ``Cookies`` DB and its WAL/SHM sidecars."""
85
+ return endpoint_browser(endpoint).profile_dir(endpoint.profile)
86
+
87
+
88
+ async def fingerprint(endpoint: BrowserEndpoint) -> Digest:
89
+ """The :func:`logical_digest` of an endpoint's store, read off its raw rows.
90
+
91
+ Decryption-free: it reads every ``EncryptedRow`` and digests their logical identity, so
92
+ it changes exactly when the logical cookie set does — which is what the anti-echo
93
+ comparison turns on.
94
+ """
95
+ return logical_digest(await read_rows(endpoint_browser(endpoint), endpoint.profile))
96
+
97
+
98
+ async def cookies_mtime(endpoint: BrowserEndpoint) -> float:
99
+ """The Unix mtime of an endpoint's live ``Cookies`` DB."""
100
+ return (await anyio.Path(endpoint_browser(endpoint).cookies_db(endpoint.profile)).stat()).st_mtime
101
+
102
+
103
+ async def watch_endpoint(endpoint: BrowserEndpoint) -> AsyncIterator[object]:
104
+ """Yield once per ``watchfiles`` change batch on an endpoint's profile directory."""
105
+ from watchfiles import awatch
106
+
107
+ async for changes in awatch(watch_dir(endpoint)):
108
+ yield changes
109
+
110
+
111
+ @dataclass(slots=True)
112
+ class EndpointState:
113
+ deadline: float = 0.0
114
+ seq: int = 0
115
+ last_applied: Digest | None = None
116
+
117
+
118
+ @dataclass(slots=True)
119
+ class Engine:
120
+ """Debounces each local endpoint's cookie-store writes into anti-echoed converges.
121
+
122
+ ``run`` opens one watcher per endpoint and, for each, coalesces a burst of filesystem
123
+ events into a single ``evaluate`` after ``settings.watch_debounce`` of quiet. ``evaluate``
124
+ fingerprints the endpoint, skips the self-induced echo and an actively-writing store,
125
+ and otherwise records the new digest before invoking ``notify``. The sync layer calls
126
+ :meth:`record_applied` right before it writes a cookie set back, so the write it triggers
127
+ is recognized as the engine's own echo.
128
+
129
+ ``now`` is a monotonic clock for the debounce deadline; ``wall`` is wall-clock time,
130
+ compared against the store's wall-clock mtime for the idle gate. The watcher source,
131
+ both clocks, mtime reader, digest function, and notify callback are injected, so the
132
+ whole core runs in tests without a real store or browser.
133
+
134
+ Example:
135
+ >>> engine = Engine(settings, notify=converge)
136
+ >>> async with anyio.create_task_group() as tg:
137
+ ... tg.start_soon(engine.run, endpoints)
138
+ """
139
+
140
+ settings: Settings
141
+ notify: Notify
142
+ watch: AwatchSource = watch_endpoint
143
+ digest: DigestFn = fingerprint
144
+ mtime: Mtime = cookies_mtime
145
+ now: Callable[[], float] = time.monotonic
146
+ wall: Callable[[], float] = time.time
147
+ sleep: Sleep = anyio.sleep
148
+ states: dict[str, EndpointState] = field(default_factory=dict)
149
+
150
+ def state(self, endpoint: BrowserEndpoint) -> EndpointState:
151
+ return self.states.setdefault(endpoint.id, EndpointState())
152
+
153
+ def current_digest(self, endpoint: BrowserEndpoint) -> Digest | None:
154
+ """The last digest the engine acted on or the sync layer recorded for ``endpoint``."""
155
+ return self.state(endpoint).last_applied
156
+
157
+ def record_applied(self, endpoint_id: str, digest: Digest) -> None:
158
+ """Record ``digest`` as the endpoint's applied state so the write it triggers is a no-op.
159
+
160
+ The sync layer calls this immediately before it writes a merged cookie set back to a
161
+ store; the watcher event that write produces then fingerprints to ``digest`` and is
162
+ suppressed as the engine's own echo.
163
+ """
164
+ self.states.setdefault(endpoint_id, EndpointState()).last_applied = digest
165
+
166
+ async def run(self, endpoints: tuple[BrowserEndpoint, ...]) -> None:
167
+ """Watch every endpoint concurrently until the surrounding task group is cancelled."""
168
+ async with anyio.create_task_group() as tg:
169
+ for endpoint in endpoints:
170
+ tg.start_soon(self.watch_loop, endpoint)
171
+
172
+ async def watch_loop(self, endpoint: BrowserEndpoint) -> None:
173
+ debounce = self.settings.watch_debounce.total_seconds()
174
+ state = self.state(endpoint)
175
+ async with anyio.create_task_group() as tg:
176
+ async for _ in self.watch(endpoint):
177
+ state.seq += 1
178
+ state.deadline = self.now() + debounce
179
+ tg.start_soon(self.fire_after, endpoint, state.seq)
180
+
181
+ async def fire_after(self, endpoint: BrowserEndpoint, seq: int) -> None:
182
+ state = self.state(endpoint)
183
+ await self.sleep(self.settings.watch_debounce.total_seconds())
184
+ if seq == state.seq and self.now() >= state.deadline:
185
+ await self.evaluate(endpoint)
186
+
187
+ async def evaluate(self, endpoint: BrowserEndpoint) -> None:
188
+ digest = await self.digest(endpoint)
189
+ state = self.state(endpoint)
190
+ if digest == state.last_applied:
191
+ return
192
+ if self.wall() - await self.mtime(endpoint) < self.settings.idle_threshold.total_seconds():
193
+ return
194
+ state.last_applied = digest
195
+ await self.notify(endpoint)