cookiesync-cli 0.1.4__tar.gz → 0.1.5__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.1.5}/PKG-INFO +1 -1
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/cli.py +20 -10
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/daemon/server.py +17 -15
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/daemon/sync.py +51 -38
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/service.py +14 -18
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/state.py +3 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/pyproject.toml +2 -1
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/LICENSE +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/README.md +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/__init__.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/__main__.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/cookie/__init__.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/cookie/backend.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/cookie/browsers.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/cookie/consent.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/cookie/crypto.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/cookie/domains.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/cookie/getcookie.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/cookie/merge.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/cookie/models.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/cookie/pipeline.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/cookie/serialize.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/cookie/stores.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/daemon/__init__.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/daemon/backend_ssh.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/daemon/cache.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/daemon/engine.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/daemon/rpc.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/daemon/session.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/daemon/wire.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/helper.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/paths.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/py.typed +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/cookiesync/registry.py +0 -0
- {cookiesync_cli-0.1.4 → cookiesync_cli-0.1.5}/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
|
|
|
@@ -293,11 +292,10 @@ def rpc_apply(browser_name: str, profile: str, origin: str | None) -> None:
|
|
|
293
292
|
|
|
294
293
|
|
|
295
294
|
@rpc_group.command("sync")
|
|
296
|
-
@click.option("--browser", "browser_name", required=True)
|
|
297
295
|
@click.option("--origin", default=None)
|
|
298
|
-
def rpc_sync(
|
|
299
|
-
"""Ask the daemon to converge
|
|
300
|
-
anyio.run(run_rpc_passthrough, "sync", {"
|
|
296
|
+
def rpc_sync(origin: str | None) -> None:
|
|
297
|
+
"""Ask the daemon to converge the union of every tracked endpoint, tagged with the notifying peer's origin."""
|
|
298
|
+
anyio.run(run_rpc_passthrough, "sync", {"origin": origin})
|
|
301
299
|
|
|
302
300
|
|
|
303
301
|
@rpc_group.command("reconcile")
|
|
@@ -330,6 +328,18 @@ async def run_rpc_passthrough(method: str, params: dict) -> None:
|
|
|
330
328
|
click.echo(json.dumps(await daemon_call(method, params)))
|
|
331
329
|
|
|
332
330
|
|
|
331
|
+
@main.command("route-consent")
|
|
332
|
+
@click.argument("target")
|
|
333
|
+
def route_consent(target: str) -> None:
|
|
334
|
+
"""Route the consent gate to TARGET first when it has a live, unlocked session."""
|
|
335
|
+
anyio.run(run_route_consent, SshTarget(target))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
async def run_route_consent(target: SshTarget) -> None:
|
|
339
|
+
await state.update(lambda s: replace(s, consent_route_to=target))
|
|
340
|
+
click.echo(f"Routing consent to {target}.")
|
|
341
|
+
|
|
342
|
+
|
|
333
343
|
@main.command(name="self")
|
|
334
344
|
def self_cmd() -> None:
|
|
335
345
|
"""Print this host's own SSH target, as reposync reports it."""
|
|
@@ -46,7 +46,7 @@ from cookiesync.daemon.backend_ssh import SshBackend
|
|
|
46
46
|
from cookiesync.daemon.engine import Engine, logical_digest
|
|
47
47
|
from cookiesync.daemon.rpc import Dispatcher
|
|
48
48
|
from cookiesync.daemon.session import has_active_session, probe_session, session_summary
|
|
49
|
-
from cookiesync.daemon.sync import Extracted, NeedsAuth, converge, reconcile
|
|
49
|
+
from cookiesync.daemon.sync import Extracted, NeedsAuth, converge, reconcile, warm_anchor
|
|
50
50
|
from cookiesync.daemon.wire import cookie_from_wire, cookie_to_wire
|
|
51
51
|
from cookiesync.state import BrowserId, SshTarget
|
|
52
52
|
from cookiesync.transport import shell_quote, ssh
|
|
@@ -189,18 +189,16 @@ class Daemon:
|
|
|
189
189
|
tg.start_soon(self.engine.run, local_endpoints)
|
|
190
190
|
|
|
191
191
|
async def notify_peers(self, endpoint: BrowserEndpoint) -> None:
|
|
192
|
-
"""A local endpoint settled:
|
|
192
|
+
"""A local endpoint settled: converge the local union here, then ssh every other host to converge."""
|
|
193
|
+
await self.handle_sync({"origin": None})
|
|
193
194
|
state = await self.load_state()
|
|
194
|
-
peers = {e.host for e in state.browsers if e.
|
|
195
|
+
peers = {e.host for e in state.browsers if e.host != state.self_target}
|
|
195
196
|
async with anyio.create_task_group() as tg:
|
|
196
197
|
for peer in peers:
|
|
197
|
-
tg.start_soon(self.notify_peer, peer,
|
|
198
|
+
tg.start_soon(self.notify_peer, peer, state.self_target)
|
|
198
199
|
|
|
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
|
-
)
|
|
200
|
+
async def notify_peer(self, peer: SshTarget, self_target: SshTarget) -> None:
|
|
201
|
+
await self.run_ssh(peer, f"cookiesync rpc sync --origin {shell_quote(self_target)}")
|
|
204
202
|
|
|
205
203
|
def dispatcher(self) -> Dispatcher:
|
|
206
204
|
"""Build the :class:`~cookiesync.daemon.rpc.Dispatcher` with every peer and local method bound."""
|
|
@@ -218,12 +216,11 @@ class Daemon:
|
|
|
218
216
|
|
|
219
217
|
async def handle_sync(self, params: dict) -> dict:
|
|
220
218
|
state = await self.load_state()
|
|
221
|
-
browser = BrowserId(params["browser"])
|
|
222
219
|
origin = SshTarget(params["origin"]) if params.get("origin") else None
|
|
223
|
-
group = [e for e in state.browsers if e.browser
|
|
224
|
-
anchor =
|
|
220
|
+
group = [e for e in state.browsers if BrowserName(e.browser) in REGISTRY]
|
|
221
|
+
anchor = await warm_anchor(group, self_target=state.self_target, cache=self.cache)
|
|
225
222
|
if anchor is None:
|
|
226
|
-
return {"converged": False, "reason": "no local endpoint
|
|
223
|
+
return {"converged": False, "reason": "no warm local endpoint to anchor the union"}
|
|
227
224
|
merged = await converge(
|
|
228
225
|
anchor,
|
|
229
226
|
[e for e in group if e is not anchor],
|
|
@@ -322,12 +319,17 @@ class Daemon:
|
|
|
322
319
|
return await self.consent.obtain_key_unprompted(browser_for(browser))
|
|
323
320
|
|
|
324
321
|
async def active_peer(self, state: State) -> SshTarget:
|
|
322
|
+
if (routed := state.consent_route_to) is not None and await self.peer_is_live(routed):
|
|
323
|
+
return routed
|
|
325
324
|
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"):
|
|
325
|
+
if await self.peer_is_live(peer):
|
|
328
326
|
return peer
|
|
329
327
|
raise AuthRequired("no peer has a live session to approve consent")
|
|
330
328
|
|
|
329
|
+
async def peer_is_live(self, peer: SshTarget) -> bool:
|
|
330
|
+
summary = json.loads(await self.run_ssh(peer, "cookiesync rpc whoami"))
|
|
331
|
+
return bool(summary.get("on_console") and not summary.get("locked"))
|
|
332
|
+
|
|
331
333
|
async def handle_get_cookies(self, params: dict) -> dict:
|
|
332
334
|
state = await self.load_state()
|
|
333
335
|
browser = BrowserId(params["browser"])
|
|
@@ -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.1.5"
|
|
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
|