cookiesync-cli 0.1.4__tar.gz → 0.2.0__tar.gz
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.
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/PKG-INFO +1 -1
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cli.py +33 -16
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/daemon/server.py +38 -27
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/daemon/sync.py +51 -38
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/service.py +14 -18
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/state.py +3 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/pyproject.toml +2 -1
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/LICENSE +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/README.md +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/__init__.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/__main__.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/__init__.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/backend.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/browsers.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/consent.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/crypto.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/domains.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/getcookie.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/merge.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/models.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/pipeline.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/serialize.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/stores.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/daemon/__init__.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/daemon/backend_ssh.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/daemon/cache.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/daemon/engine.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/daemon/rpc.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/daemon/session.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/daemon/wire.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/helper.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/paths.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/py.typed +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/registry.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/transport.py +0 -0
|
@@ -206,14 +206,13 @@ async def run_reconcile() -> None:
|
|
|
206
206
|
|
|
207
207
|
|
|
208
208
|
@main.command("sync")
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
anyio.run(run_sync, browser_name)
|
|
209
|
+
def sync_cmd() -> None:
|
|
210
|
+
"""Ask the daemon to converge the union of every tracked endpoint across this host and its peers."""
|
|
211
|
+
anyio.run(run_sync)
|
|
213
212
|
|
|
214
213
|
|
|
215
|
-
async def run_sync(
|
|
216
|
-
result = await daemon_call("sync", {
|
|
214
|
+
async def run_sync() -> None:
|
|
215
|
+
result = await daemon_call("sync", {})
|
|
217
216
|
click.echo(json.dumps(result, indent=2))
|
|
218
217
|
|
|
219
218
|
|
|
@@ -236,7 +235,7 @@ async def run_auth(browser_name: str, profile: str, reason: str | None, ttl: str
|
|
|
236
235
|
|
|
237
236
|
|
|
238
237
|
@main.command()
|
|
239
|
-
@click.argument("
|
|
238
|
+
@click.argument("urls", nargs=-1, required=True)
|
|
240
239
|
@click.option(
|
|
241
240
|
"--browser", "browser_name", default="chrome", show_default=True, help="The browser to read cookies from."
|
|
242
241
|
)
|
|
@@ -249,13 +248,20 @@ async def run_auth(browser_name: str, profile: str, reason: str | None, ttl: str
|
|
|
249
248
|
show_default=True,
|
|
250
249
|
help="The output wire format.",
|
|
251
250
|
)
|
|
252
|
-
def cookies(
|
|
253
|
-
"""Stream
|
|
254
|
-
anyio.run(run_cookies, url, browser_name, profile, fmt)
|
|
251
|
+
def cookies(urls: tuple[str, ...], browser_name: str, profile: str, fmt: str) -> None:
|
|
252
|
+
"""Stream the cookies for one or more URLS in the chosen format, merged into one document.
|
|
255
253
|
|
|
254
|
+
Pass several hosts (e.g. an app and the API host it calls) to get a single storageState
|
|
255
|
+
spanning them all — one cached-key decrypt, no extra Touch ID prompt.
|
|
256
|
+
"""
|
|
257
|
+
anyio.run(run_cookies, urls, browser_name, profile, fmt)
|
|
256
258
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
+
|
|
260
|
+
async def run_cookies(urls: tuple[str, ...], browser_name: str, profile: str, fmt: str) -> None:
|
|
261
|
+
# Dual-field wire: a new daemon prefers "urls" and merges every host; an older resident
|
|
262
|
+
# daemon still reads "url" and serves the first host, so the call degrades, never crashes.
|
|
263
|
+
params = {"url": urls[0], "urls": list(urls), "browser": browser_name, "profile": profile}
|
|
264
|
+
result = await daemon_call("get_cookies", params)
|
|
259
265
|
state_obj = StorageState(tuple(cookie_from_wire(c) for c in result["cookies"]))
|
|
260
266
|
for line in render(state_obj, OutputFormat(fmt)):
|
|
261
267
|
click.echo(line)
|
|
@@ -293,11 +299,10 @@ def rpc_apply(browser_name: str, profile: str, origin: str | None) -> None:
|
|
|
293
299
|
|
|
294
300
|
|
|
295
301
|
@rpc_group.command("sync")
|
|
296
|
-
@click.option("--browser", "browser_name", required=True)
|
|
297
302
|
@click.option("--origin", default=None)
|
|
298
|
-
def rpc_sync(
|
|
299
|
-
"""Ask the daemon to converge
|
|
300
|
-
anyio.run(run_rpc_passthrough, "sync", {"
|
|
303
|
+
def rpc_sync(origin: str | None) -> None:
|
|
304
|
+
"""Ask the daemon to converge the union of every tracked endpoint, tagged with the notifying peer's origin."""
|
|
305
|
+
anyio.run(run_rpc_passthrough, "sync", {"origin": origin})
|
|
301
306
|
|
|
302
307
|
|
|
303
308
|
@rpc_group.command("reconcile")
|
|
@@ -330,6 +335,18 @@ async def run_rpc_passthrough(method: str, params: dict) -> None:
|
|
|
330
335
|
click.echo(json.dumps(await daemon_call(method, params)))
|
|
331
336
|
|
|
332
337
|
|
|
338
|
+
@main.command("route-consent")
|
|
339
|
+
@click.argument("target")
|
|
340
|
+
def route_consent(target: str) -> None:
|
|
341
|
+
"""Route the consent gate to TARGET first when it has a live, unlocked session."""
|
|
342
|
+
anyio.run(run_route_consent, SshTarget(target))
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
async def run_route_consent(target: SshTarget) -> None:
|
|
346
|
+
await state.update(lambda s: replace(s, consent_route_to=target))
|
|
347
|
+
click.echo(f"Routing consent to {target}.")
|
|
348
|
+
|
|
349
|
+
|
|
333
350
|
@main.command(name="self")
|
|
334
351
|
def self_cmd() -> None:
|
|
335
352
|
"""Print this host's own SSH target, as reposync reports it."""
|
|
@@ -15,8 +15,9 @@ The method set splits in two:
|
|
|
15
15
|
* **Local methods** are terminal and carry no origin — what the CLI on this box invokes:
|
|
16
16
|
``prime_auth`` obtains the Safe Storage key (locally behind Touch ID when a session is live,
|
|
17
17
|
else by routing the user-presence gate to the active peer and then releasing this host's
|
|
18
|
-
*own* key non-interactively) and caches it; ``get_cookies`` renders
|
|
19
|
-
cached key, failing closed when cold; ``auth_status`` reports cache
|
|
18
|
+
*own* key non-interactively) and caches it; ``get_cookies`` renders one or more urls' cookies,
|
|
19
|
+
merged into one set, from the cached key, failing closed when cold; ``auth_status`` reports cache
|
|
20
|
+
warmth; ``request_consent``
|
|
20
21
|
shows the Touch-ID prompt for the named browser to the person at *this* machine and echoes the
|
|
21
22
|
requester's nonce + endpoint to bind the approval — the key never crosses hosts.
|
|
22
23
|
|
|
@@ -35,7 +36,7 @@ from typing import TYPE_CHECKING
|
|
|
35
36
|
import anyio
|
|
36
37
|
|
|
37
38
|
from cookiesync import state as state_module
|
|
38
|
-
from cookiesync.cookie import LocalBackend, extract
|
|
39
|
+
from cookiesync.cookie import LocalBackend, extract, merge
|
|
39
40
|
from cookiesync.cookie.browsers import REGISTRY, BrowserName
|
|
40
41
|
from cookiesync.cookie.consent import ConsentError
|
|
41
42
|
from cookiesync.cookie.crypto import DecryptError, decrypt_value
|
|
@@ -46,7 +47,7 @@ from cookiesync.daemon.backend_ssh import SshBackend
|
|
|
46
47
|
from cookiesync.daemon.engine import Engine, logical_digest
|
|
47
48
|
from cookiesync.daemon.rpc import Dispatcher
|
|
48
49
|
from cookiesync.daemon.session import has_active_session, probe_session, session_summary
|
|
49
|
-
from cookiesync.daemon.sync import Extracted, NeedsAuth, converge, reconcile
|
|
50
|
+
from cookiesync.daemon.sync import Extracted, NeedsAuth, converge, reconcile, warm_anchor
|
|
50
51
|
from cookiesync.daemon.wire import cookie_from_wire, cookie_to_wire
|
|
51
52
|
from cookiesync.state import BrowserId, SshTarget
|
|
52
53
|
from cookiesync.transport import shell_quote, ssh
|
|
@@ -189,18 +190,16 @@ class Daemon:
|
|
|
189
190
|
tg.start_soon(self.engine.run, local_endpoints)
|
|
190
191
|
|
|
191
192
|
async def notify_peers(self, endpoint: BrowserEndpoint) -> None:
|
|
192
|
-
"""A local endpoint settled:
|
|
193
|
+
"""A local endpoint settled: converge the local union here, then ssh every other host to converge."""
|
|
194
|
+
await self.handle_sync({"origin": None})
|
|
193
195
|
state = await self.load_state()
|
|
194
|
-
peers = {e.host for e in state.browsers if e.
|
|
196
|
+
peers = {e.host for e in state.browsers if e.host != state.self_target}
|
|
195
197
|
async with anyio.create_task_group() as tg:
|
|
196
198
|
for peer in peers:
|
|
197
|
-
tg.start_soon(self.notify_peer, peer,
|
|
199
|
+
tg.start_soon(self.notify_peer, peer, state.self_target)
|
|
198
200
|
|
|
199
|
-
async def notify_peer(self, peer: SshTarget,
|
|
200
|
-
await self.run_ssh(
|
|
201
|
-
peer,
|
|
202
|
-
f"cookiesync rpc sync --browser {shell_quote(browser)} --origin {shell_quote(self_target)}",
|
|
203
|
-
)
|
|
201
|
+
async def notify_peer(self, peer: SshTarget, self_target: SshTarget) -> None:
|
|
202
|
+
await self.run_ssh(peer, f"cookiesync rpc sync --origin {shell_quote(self_target)}")
|
|
204
203
|
|
|
205
204
|
def dispatcher(self) -> Dispatcher:
|
|
206
205
|
"""Build the :class:`~cookiesync.daemon.rpc.Dispatcher` with every peer and local method bound."""
|
|
@@ -218,12 +217,11 @@ class Daemon:
|
|
|
218
217
|
|
|
219
218
|
async def handle_sync(self, params: dict) -> dict:
|
|
220
219
|
state = await self.load_state()
|
|
221
|
-
browser = BrowserId(params["browser"])
|
|
222
220
|
origin = SshTarget(params["origin"]) if params.get("origin") else None
|
|
223
|
-
group = [e for e in state.browsers if e.browser
|
|
224
|
-
anchor =
|
|
221
|
+
group = [e for e in state.browsers if BrowserName(e.browser) in REGISTRY]
|
|
222
|
+
anchor = await warm_anchor(group, self_target=state.self_target, cache=self.cache)
|
|
225
223
|
if anchor is None:
|
|
226
|
-
return {"converged": False, "reason": "no local endpoint
|
|
224
|
+
return {"converged": False, "reason": "no warm local endpoint to anchor the union"}
|
|
227
225
|
merged = await converge(
|
|
228
226
|
anchor,
|
|
229
227
|
[e for e in group if e is not anchor],
|
|
@@ -322,12 +320,17 @@ class Daemon:
|
|
|
322
320
|
return await self.consent.obtain_key_unprompted(browser_for(browser))
|
|
323
321
|
|
|
324
322
|
async def active_peer(self, state: State) -> SshTarget:
|
|
323
|
+
if (routed := state.consent_route_to) is not None and await self.peer_is_live(routed):
|
|
324
|
+
return routed
|
|
325
325
|
for peer in {e.host for e in state.browsers if e.host != state.self_target}:
|
|
326
|
-
|
|
327
|
-
if summary.get("on_console") and not summary.get("locked"):
|
|
326
|
+
if await self.peer_is_live(peer):
|
|
328
327
|
return peer
|
|
329
328
|
raise AuthRequired("no peer has a live session to approve consent")
|
|
330
329
|
|
|
330
|
+
async def peer_is_live(self, peer: SshTarget) -> bool:
|
|
331
|
+
summary = json.loads(await self.run_ssh(peer, "cookiesync rpc whoami"))
|
|
332
|
+
return bool(summary.get("on_console") and not summary.get("locked"))
|
|
333
|
+
|
|
331
334
|
async def handle_get_cookies(self, params: dict) -> dict:
|
|
332
335
|
state = await self.load_state()
|
|
333
336
|
browser = BrowserId(params["browser"])
|
|
@@ -336,15 +339,23 @@ class Daemon:
|
|
|
336
339
|
raise AuthRequired(
|
|
337
340
|
f"no cached key for {endpoint_id(state.self_target, browser, profile)}; run cookiesync auth"
|
|
338
341
|
)
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
342
|
+
# New CLIs send "urls" (one or more hosts); older ones send a single "url". Decrypt each
|
|
343
|
+
# host with the same cached key — no extra prompt — and union them by logical identity, so
|
|
344
|
+
# a domain cookie shared by overlapping hosts collapses to one row.
|
|
345
|
+
urls = params.get("urls") or [params["url"]]
|
|
346
|
+
backend = LocalBackend(self.consent)
|
|
347
|
+
states = [
|
|
348
|
+
await extract(
|
|
349
|
+
url,
|
|
350
|
+
browser=browser_for(browser),
|
|
351
|
+
key=AesKey(key),
|
|
352
|
+
backend=backend,
|
|
353
|
+
profile=profile,
|
|
354
|
+
fallback=False,
|
|
355
|
+
)
|
|
356
|
+
for url in urls
|
|
357
|
+
]
|
|
358
|
+
return {"cookies": [cookie_to_wire(c) for c in merge(*(s.cookies for s in states))]}
|
|
348
359
|
|
|
349
360
|
async def handle_auth_status(self, params: dict) -> dict:
|
|
350
361
|
state = await self.load_state()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""The sync merge pass: gather every endpoint's cookies, union newest-wins, idempotently apply.
|
|
2
2
|
|
|
3
|
-
``converge`` runs one merge pass
|
|
3
|
+
``converge`` runs one merge pass over the union of every tracked endpoint. It decrypts this
|
|
4
4
|
host's cookies (via the cached Safe Storage key — never prompting here), pulls each peer's
|
|
5
5
|
decrypted cookies over ssh, merges with the pure union newest-wins rule, then writes the
|
|
6
6
|
merged set back to any endpoint whose rows differ — preserving the winning
|
|
@@ -13,7 +13,7 @@ machines without any clock-skew correction. The merge preserves each winner's or
|
|
|
13
13
|
``last_update_utc`` on every host, so the anti-echo digest the watch engine records matches
|
|
14
14
|
the store's fingerprint after the write.
|
|
15
15
|
|
|
16
|
-
``reconcile`` is the time-based backup: ``converge`` over every tracked
|
|
16
|
+
``reconcile`` is the time-based backup: one ``converge`` over the union of every tracked endpoint.
|
|
17
17
|
|
|
18
18
|
This host and every peer are reached through the one uniform :class:`Source` seam
|
|
19
19
|
(``extract``/``apply``), so the merge logic runs in unit tests against fakes — with the
|
|
@@ -22,10 +22,11 @@ sources injected — without ssh or a real cookie store.
|
|
|
22
22
|
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
|
-
from collections import defaultdict
|
|
26
25
|
from dataclasses import dataclass
|
|
27
26
|
from typing import TYPE_CHECKING, Protocol
|
|
28
27
|
|
|
28
|
+
from loguru import logger
|
|
29
|
+
|
|
29
30
|
from cookiesync.cookie import merge
|
|
30
31
|
from cookiesync.daemon.engine import logical_digest
|
|
31
32
|
|
|
@@ -112,6 +113,16 @@ def row_set(cookies: Iterable[Cookie]) -> frozenset[tuple]:
|
|
|
112
113
|
return frozenset(target_row(c) for c in cookies)
|
|
113
114
|
|
|
114
115
|
|
|
116
|
+
async def warm_anchor(
|
|
117
|
+
endpoints: Sequence[BrowserEndpoint], *, self_target: SshTarget, cache: KeyCache
|
|
118
|
+
) -> BrowserEndpoint | None:
|
|
119
|
+
"""The first local endpoint with a warm key — the anchor a union converge merges onto."""
|
|
120
|
+
for endpoint in endpoints:
|
|
121
|
+
if endpoint.host == self_target and await cache.get(endpoint.id) is not None:
|
|
122
|
+
return endpoint
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
115
126
|
async def gather(endpoint: BrowserEndpoint, source: Source) -> Gathered:
|
|
116
127
|
extracted = await source.extract(endpoint.browser, endpoint.profile)
|
|
117
128
|
return Gathered(endpoint, source, extracted.cookies)
|
|
@@ -136,22 +147,24 @@ async def converge(
|
|
|
136
147
|
local_source: Source,
|
|
137
148
|
source_for: Callable[[SshTarget], Source],
|
|
138
149
|
) -> tuple[Cookie, ...]:
|
|
139
|
-
"""Merge
|
|
150
|
+
"""Merge the union of every endpoint across this host and its peers, then idempotently apply.
|
|
140
151
|
|
|
141
152
|
Gathers ``endpoint``'s decrypted cookies through ``local_source`` (the consent gate is
|
|
142
153
|
the caller's; a cold key cache raises :class:`NeedsAuth` rather than prompting) and each
|
|
143
154
|
peer's cookies through ``source_for(peer.host)``, skipping ``origin`` so a sync is never
|
|
144
|
-
echoed straight back to the host that triggered it
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
``last_update_utc``
|
|
149
|
-
|
|
150
|
-
``
|
|
155
|
+
echoed straight back to the host that triggered it, and skipping a same-host peer whose
|
|
156
|
+
key is cold (logged, not silent — its consent never ran). Remote peers are always
|
|
157
|
+
included; their warmth is resolved remotely by the peer's own prime-on-cold extract. The
|
|
158
|
+
union newest-wins :func:`~cookiesync.cookie.merge` selects per cookie by raw
|
|
159
|
+
``last_update_utc`` — absolute Chrome time, host-independent and convergent on NTP-synced
|
|
160
|
+
machines — and the result is written to any endpoint whose stored rows differ, preserving
|
|
161
|
+
the winning ``last_update_utc`` and recording the applied digest with ``engine`` *before*
|
|
162
|
+
the write, so the induced filesystem event is suppressed. Same-machine endpoints converge
|
|
163
|
+
through ``local_source`` in-process, with no ssh.
|
|
151
164
|
|
|
152
165
|
Args:
|
|
153
|
-
endpoint: This host's local endpoint
|
|
154
|
-
peers: The other tracked endpoints
|
|
166
|
+
endpoint: This host's warm-keyed local anchor endpoint.
|
|
167
|
+
peers: The other tracked endpoints across every browser and host, local or remote.
|
|
155
168
|
origin: The host that triggered this sync, skipped to avoid an echo; ``None`` for a
|
|
156
169
|
time-based reconcile that touches every endpoint.
|
|
157
170
|
self_target: This host's own ssh target; endpoints on it converge in-process.
|
|
@@ -173,14 +186,15 @@ async def converge(
|
|
|
173
186
|
"""
|
|
174
187
|
if await cache.get(endpoint.id) is None:
|
|
175
188
|
raise NeedsAuth(f"no cached key for {endpoint.id}; obtain consent before converging")
|
|
176
|
-
sources = [
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
189
|
+
sources: list[tuple[BrowserEndpoint, Source]] = [(endpoint, local_source)]
|
|
190
|
+
for peer in (p for p in peers if p.host != origin):
|
|
191
|
+
match peer.host == self_target:
|
|
192
|
+
case True if await cache.get(peer.id) is None:
|
|
193
|
+
logger.warning("skipping cold same-host endpoint {} from union converge", peer.id)
|
|
194
|
+
case True:
|
|
195
|
+
sources.append((peer, local_source))
|
|
196
|
+
case False:
|
|
197
|
+
sources.append((peer, source_for(peer.host)))
|
|
184
198
|
gathered = [await gather(ep, src) for ep, src in sources]
|
|
185
199
|
merged = merge(*(g.cookies for g in gathered))
|
|
186
200
|
for g in gathered:
|
|
@@ -198,38 +212,37 @@ async def reconcile(
|
|
|
198
212
|
local_source: Source,
|
|
199
213
|
source_for: Callable[[SshTarget], Source],
|
|
200
214
|
) -> dict[str, tuple[Cookie, ...]]:
|
|
201
|
-
"""The time-based backup:
|
|
215
|
+
"""The time-based backup: one :func:`converge` over the union of every tracked endpoint.
|
|
202
216
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
217
|
+
Every registered endpoint — every browser, every host — converges to one union cookie
|
|
218
|
+
set. The pass is anchored on this host's first warm-keyed local endpoint and run with no
|
|
219
|
+
``origin`` so every endpoint is reconciled. With no warm local anchor there is nothing
|
|
220
|
+
here to merge from, so the pass is skipped.
|
|
206
221
|
|
|
207
222
|
Args:
|
|
208
223
|
endpoints: Every tracked endpoint across all hosts and browsers.
|
|
209
224
|
self_target: This host's own ssh target.
|
|
210
225
|
registry: The browser registry, mapping each :class:`~cookiesync.state.BrowserId` to
|
|
211
|
-
its :class:`~cookiesync.cookie.browsers.Browser`;
|
|
212
|
-
|
|
213
|
-
cache: The short-TTL key cache.
|
|
226
|
+
its :class:`~cookiesync.cookie.browsers.Browser`; endpoints whose browser is not
|
|
227
|
+
registered are excluded from the union.
|
|
228
|
+
cache: The short-TTL key cache; the anchor is the first local endpoint with a warm key.
|
|
214
229
|
engine: The watch engine, told each applied digest before its write.
|
|
215
230
|
local_source: This machine's cookie source.
|
|
216
231
|
source_for: Builds the :class:`Source` for a peer target; injected for tests.
|
|
217
232
|
|
|
218
233
|
Returns:
|
|
219
|
-
|
|
234
|
+
The anchor endpoint's id mapped to the merged union, or empty when no warm anchor.
|
|
220
235
|
|
|
221
236
|
Example:
|
|
222
237
|
>>> await reconcile(endpoints, self_target=self, registry=REGISTRY, cache=cache,
|
|
223
238
|
... engine=engine, local_source=local, source_for=make_ssh_source)
|
|
224
239
|
"""
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
continue
|
|
232
|
-
results[anchor.id] = await converge(
|
|
240
|
+
group = [e for e in endpoints if e.browser in registry]
|
|
241
|
+
anchor = await warm_anchor(group, self_target=self_target, cache=cache)
|
|
242
|
+
if anchor is None:
|
|
243
|
+
return {}
|
|
244
|
+
return {
|
|
245
|
+
anchor.id: await converge(
|
|
233
246
|
anchor,
|
|
234
247
|
[e for e in group if e is not anchor],
|
|
235
248
|
self_target=self_target,
|
|
@@ -238,4 +251,4 @@ async def reconcile(
|
|
|
238
251
|
local_source=local_source,
|
|
239
252
|
source_for=source_for,
|
|
240
253
|
)
|
|
241
|
-
|
|
254
|
+
}
|
|
@@ -41,11 +41,11 @@ SESSION_TYPE = "Aqua"
|
|
|
41
41
|
|
|
42
42
|
RECONCILE_INTERVAL = 900
|
|
43
43
|
|
|
44
|
-
# launchctl
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
# launchctl bootout returns exit 3 (ESRCH) when the target agent isn't loaded — the only
|
|
45
|
+
# tolerated failure, so uninstall and re-install of an absent agent are idempotent. Install
|
|
46
|
+
# boots out before bootstrap (bootstrap_agent), so bootstrap never races an already-loaded
|
|
47
|
+
# agent; any nonzero bootstrap exit is a real error (a malformed plist also exits nonzero).
|
|
48
|
+
NOT_LOADED_EXIT = 3
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
class ServiceError(Exception):
|
|
@@ -106,16 +106,17 @@ class Launcher(Protocol):
|
|
|
106
106
|
class LaunchctlLauncher:
|
|
107
107
|
"""The production :class:`Launcher`: shells out to ``launchctl bootstrap``/``bootout``.
|
|
108
108
|
|
|
109
|
-
Both verbs target the caller's ``gui/<uid>`` domain.
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
Both verbs target the caller's ``gui/<uid>`` domain. ``bootout`` tolerates exit 3
|
|
110
|
+
(``ESRCH`` — the agent wasn't loaded) so uninstall and re-install stay idempotent;
|
|
111
|
+
``bootstrap`` tolerates nothing, since install boots out first. Any other non-zero
|
|
112
|
+
exit raises :class:`ServiceError`.
|
|
112
113
|
"""
|
|
113
114
|
|
|
114
115
|
async def bootstrap(self, plist: Path) -> None:
|
|
115
|
-
await run_launchctl("bootstrap", gui_domain(), str(plist)
|
|
116
|
+
await run_launchctl("bootstrap", gui_domain(), str(plist))
|
|
116
117
|
|
|
117
118
|
async def bootout(self, label: Label) -> None:
|
|
118
|
-
await run_launchctl("bootout", f"{gui_domain()}/{label}",
|
|
119
|
+
await run_launchctl("bootout", f"{gui_domain()}/{label}", ok=(NOT_LOADED_EXIT,))
|
|
119
120
|
|
|
120
121
|
|
|
121
122
|
def gui_domain() -> str:
|
|
@@ -168,15 +169,10 @@ def agent_for(label: Label, program_args: Sequence[str]) -> AgentSpec:
|
|
|
168
169
|
raise ServiceError(f"{label} runs {agent.command!r}, not {list(program_args)!r}")
|
|
169
170
|
|
|
170
171
|
|
|
171
|
-
async def run_launchctl(*args: str,
|
|
172
|
+
async def run_launchctl(*args: str, ok: tuple[int, ...] = ()) -> None:
|
|
172
173
|
result = await anyio.run_process(["launchctl", *args], check=False)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
return
|
|
176
|
-
case _ if any(t in result.stderr.decode() for t in tolerate):
|
|
177
|
-
return
|
|
178
|
-
case code:
|
|
179
|
-
raise ServiceError(f"launchctl {args[0]}: exit {code}: {result.stderr.decode().strip()}")
|
|
174
|
+
if (code := result.returncode) and code not in ok:
|
|
175
|
+
raise ServiceError(f"launchctl {args[0]}: exit {code}: {result.stderr.decode().strip()}")
|
|
180
176
|
|
|
181
177
|
|
|
182
178
|
async def install(launcher: Launcher, *, tick_only: bool = False) -> None:
|
|
@@ -102,12 +102,14 @@ class State:
|
|
|
102
102
|
self_target: SshTarget
|
|
103
103
|
browsers: tuple[BrowserEndpoint, ...] = ()
|
|
104
104
|
settings: Settings = field(default_factory=Settings)
|
|
105
|
+
consent_route_to: SshTarget | None = None
|
|
105
106
|
|
|
106
107
|
def to_json(self) -> dict[str, object]:
|
|
107
108
|
return {
|
|
108
109
|
"self_target": self.self_target,
|
|
109
110
|
"browsers": [endpoint.to_json() for endpoint in self.browsers],
|
|
110
111
|
"settings": self.settings.to_json(),
|
|
112
|
+
"consent_route_to": self.consent_route_to,
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
@classmethod
|
|
@@ -116,6 +118,7 @@ class State:
|
|
|
116
118
|
SshTarget(raw["self_target"]),
|
|
117
119
|
tuple(BrowserEndpoint.from_json(endpoint) for endpoint in raw["browsers"]),
|
|
118
120
|
Settings.from_json(raw["settings"]),
|
|
121
|
+
SshTarget(route) if (route := raw.get("consent_route_to")) is not None else None,
|
|
119
122
|
)
|
|
120
123
|
|
|
121
124
|
async def save(self) -> State:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "cookiesync-cli"
|
|
3
|
-
version
|
|
3
|
+
# Inert sentinel: the real version is set from the release tag (uv version --frozen); never written back here.
|
|
4
|
+
version = "0.2.0"
|
|
4
5
|
description = "Sync your browser cookies across machines."
|
|
5
6
|
readme = "README.md"
|
|
6
7
|
license = "PolyForm-Noncommercial-1.0.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|