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.
Files changed (35) hide show
  1. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/PKG-INFO +1 -1
  2. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cli.py +33 -16
  3. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/daemon/server.py +38 -27
  4. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/daemon/sync.py +51 -38
  5. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/service.py +14 -18
  6. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/state.py +3 -0
  7. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/pyproject.toml +2 -1
  8. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/LICENSE +0 -0
  9. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/README.md +0 -0
  10. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/__init__.py +0 -0
  11. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/__main__.py +0 -0
  12. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/__init__.py +0 -0
  13. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/backend.py +0 -0
  14. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/browsers.py +0 -0
  15. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/consent.py +0 -0
  16. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/crypto.py +0 -0
  17. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/domains.py +0 -0
  18. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/getcookie.py +0 -0
  19. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/merge.py +0 -0
  20. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/models.py +0 -0
  21. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/pipeline.py +0 -0
  22. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/serialize.py +0 -0
  23. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/cookie/stores.py +0 -0
  24. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/daemon/__init__.py +0 -0
  25. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/daemon/backend_ssh.py +0 -0
  26. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/daemon/cache.py +0 -0
  27. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/daemon/engine.py +0 -0
  28. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/daemon/rpc.py +0 -0
  29. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/daemon/session.py +0 -0
  30. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/daemon/wire.py +0 -0
  31. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/helper.py +0 -0
  32. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/paths.py +0 -0
  33. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/py.typed +0 -0
  34. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/registry.py +0 -0
  35. {cookiesync_cli-0.1.4 → cookiesync_cli-0.2.0}/cookiesync/transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cookiesync-cli
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: Sync your browser cookies across machines.
5
5
  Keywords:
6
6
  Author: Yasyf Mohamedali
@@ -206,14 +206,13 @@ async def run_reconcile() -> None:
206
206
 
207
207
 
208
208
  @main.command("sync")
209
- @click.option("--browser", "browser_name", required=True, help="The browser group to converge.")
210
- def sync_cmd(browser_name: str) -> None:
211
- """Ask the daemon to converge one browser group across this host and its peers."""
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(browser_name: str) -> None:
216
- result = await daemon_call("sync", {"browser": browser_name})
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("url")
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(url: str, browser_name: str, profile: str, fmt: str) -> None:
253
- """Stream URL's cookies in the chosen format, decrypting with the daemon's cached key."""
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
- async def run_cookies(url: str, browser_name: str, profile: str, fmt: str) -> None:
258
- result = await daemon_call("get_cookies", {"url": url, "browser": browser_name, "profile": profile})
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(browser_name: str, origin: str | None) -> None:
299
- """Ask the daemon to converge one browser group, tagged with the notifying peer's origin."""
300
- anyio.run(run_rpc_passthrough, "sync", {"browser": browser_name, "origin": origin})
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 a url's cookies from the
19
- cached key, failing closed when cold; ``auth_status`` reports cache warmth; ``request_consent``
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: ssh every peer to converge that browser, tagged with our origin."""
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.browser == endpoint.browser and e.host != state.self_target}
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, endpoint.browser, state.self_target)
199
+ tg.start_soon(self.notify_peer, peer, state.self_target)
198
200
 
199
- async def notify_peer(self, peer: SshTarget, browser: BrowserId, self_target: SshTarget) -> None:
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 == browser]
224
- anchor = next((e for e in group if e.host == state.self_target), None)
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 for this browser"}
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
- summary = json.loads(await self.run_ssh(peer, "cookiesync rpc whoami"))
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
- result = await extract(
340
- params["url"],
341
- browser=browser_for(browser),
342
- key=AesKey(key),
343
- backend=LocalBackend(self.consent),
344
- profile=profile,
345
- fallback=False,
346
- )
347
- return {"cookies": [cookie_to_wire(c) for c in result.cookies]}
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 for a single tracked browser group. It decrypts this
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 browser group.
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 one browser group across this host and its peers, then idempotently apply.
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. The union newest-wins
145
- :func:`~cookiesync.cookie.merge` selects per cookie by raw ``last_update_utc`` absolute
146
- Chrome time, host-independent and convergent on NTP-synced machines and the result is
147
- written to any endpoint whose stored rows differ, preserving the winning
148
- ``last_update_utc`` and recording the applied digest with ``engine`` *before* the write,
149
- so the induced filesystem event is suppressed. Same-machine endpoints converge through
150
- ``local_source`` in-process, with no ssh.
155
+ echoed straight back to the host that triggered it, and skipping a same-host peer whose
156
+ key is cold (logged, not silentits 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 for the browser group.
154
- peers: The other tracked endpoints for the same browser, local or remote.
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
- (endpoint, local_source),
178
- *(
179
- (peer, local_source if peer.host == self_target else source_for(peer.host))
180
- for peer in peers
181
- if peer.host != origin
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: ``converge`` over every tracked browser group.
215
+ """The time-based backup: one :func:`converge` over the union of every tracked endpoint.
202
216
 
203
- Groups ``endpoints`` by browser, anchors each group on this host's local endpoint, and
204
- runs :func:`converge` with no ``origin`` so every endpoint is reconciled. A group with no
205
- local endpoint on this host is skipped there is nothing here to merge from.
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`; the anchored group is skipped
212
- when its browser is not registered.
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
- Each anchored endpoint's id mapped to the merged set reconciled for its group.
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
- groups: dict[BrowserId, list[BrowserEndpoint]] = defaultdict(list)
226
- for endpoint in endpoints:
227
- groups[endpoint.browser].append(endpoint)
228
- results: dict[str, tuple[Cookie, ...]] = {}
229
- for browser_id, group in groups.items():
230
- if browser_id not in registry or (anchor := next((e for e in group if e.host == self_target), None)) is None:
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
- return results
254
+ }
@@ -41,11 +41,11 @@ SESSION_TYPE = "Aqua"
41
41
 
42
42
  RECONCILE_INTERVAL = 900
43
43
 
44
- # launchctl's tolerated-error strings vary by macOS version: pre-13 said "Could not
45
- # find specified service" / "service already loaded"; 13+ says "No such process" (exit 3)
46
- # for a not-loaded bootout. Tolerate every spelling so install/uninstall stay idempotent.
47
- ALREADY_LOADED = ("service already loaded", "service already bootstrapped")
48
- NOT_LOADED = ("Could not find specified service", "No such process")
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. An "already loaded" bootstrap
110
- and a "not loaded" bootout are tolerated so install and uninstall are idempotent;
111
- any other non-zero exit raises :class:`ServiceError`.
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), tolerate=ALREADY_LOADED)
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}", tolerate=NOT_LOADED)
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, tolerate: tuple[str, ...]) -> None:
172
+ async def run_launchctl(*args: str, ok: tuple[int, ...] = ()) -> None:
172
173
  result = await anyio.run_process(["launchctl", *args], check=False)
173
- match result.returncode:
174
- case 0:
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 = "0.1.4"
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