Skip to content

Commit 2d71b42

Browse files
committed
refactor(extras): make the client cluster real packages, not sys.path
chronicle-tray reached into vault-sync and local-wearable-client by putting their directories on sys.path. Both are flat-module projects that each ship a main.py *and* a service.py, so correctness depended on the wearable dir being insert(0, ...) while the vault dir was append(...) — otherwise `import main` silently resolved to the wrong project. The hazard was documented in paths.py's docstring, which is a sign it had already bitten someone. Meanwhile four modules each re-derived the same connection facts: the repo-root .env load, the discovery fallback, the http->ws derivation and the CHRONICLE_API_KEY read. The API-key work made that worse by adding the key read in three places at once. Add extras/chronicle-client with ClientConfig.from_env() and the credential check, and turn vault-sync and local-wearable-client into chronicle_vault_sync and chronicle_wearable. The tray now declares all three as editable path dependencies — the pattern friend-lite-sdk already used here — so the sibling path helpers and their ordering constraint are gone. Two details worth knowing: - The package finds the checkout by looking for discovery.py + setup_utils.py rather than counting parent directories. Counting only holds for an editable install; a regular install lands in site-packages, where it silently yields the wrong root and an empty API key. - havpe-relay gets the package through a *second* build context rather than widening its own. Its main context stays extras/havpe-relay, so the build does not walk the 4.9 GB extras/ tree. Because WORKDIR is /app and the package lands at /chronicle-client, the ../chronicle-client path in pyproject.toml resolves identically on the host and in the image. Also mark rumps macOS-only. It is only used by the lazily-imported menu app, but as an unconditional dependency it pulled pyobjc, which cannot build on Linux — so this project was previously not installable there at all. discovery.py and setup_utils.py stay at the repo root; that is a separate change with a much larger blast radius.
1 parent 1f1f127 commit 2d71b42

40 files changed

Lines changed: 2838 additions & 2108 deletions

‎extras/chronicle-client/README.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# chronicle-client
2+
3+
Shared configuration and auth for Chronicle's native client apps.
4+
5+
`chronicle-tray`, `vault-sync`, `local-wearable-client` and `havpe-relay` all need
6+
the same four facts — where the backend is, its WebSocket URL, which API key to
7+
present, and what this device is called — and each used to derive them itself.
8+
This package is the single source.
9+
10+
```python
11+
from chronicle_client import ClientConfig, check_credentials
12+
13+
config = ClientConfig.from_env()
14+
if not check_credentials(config.api_key, config.backend_url):
15+
...
16+
```
17+
18+
`ClientConfig.from_env()` loads the repository-root `.env` (the one shared by all
19+
native client components), resolves `BACKEND_URL` through Tailnet discovery when
20+
it isn't set explicitly, and reads `CHRONICLE_API_KEY`.
21+
22+
## Why an API key
23+
24+
A Chronicle JWT expires after 24h. These clients store one credential and never
25+
see a login form again, so a JWT would either break daily or force them to keep
26+
the account password around to re-login. Mint a long-lived key in the web UI
27+
under **Settings → API Keys**.
28+
29+
## Consuming it
30+
31+
```toml
32+
[tool.uv.sources]
33+
chronicle-client = { path = "../chronicle-client", editable = true }
34+
```
35+
36+
For a containerised consumer, pass the package as a second build context so the
37+
main context stays narrow — see `extras/havpe-relay/docker-compose.yml`.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Shared configuration and auth for Chronicle's native client apps.
2+
3+
Used by chronicle-tray, vault sync, the wearable client and the havpe relay so
4+
each one stops re-deriving the backend URL, the repo-root ``.env`` load and the
5+
API-key handling for itself.
6+
"""
7+
8+
from chronicle_client.auth import (
9+
acheck_credentials,
10+
auth_headers,
11+
bearer_query_param,
12+
check_credentials,
13+
describe_key,
14+
)
15+
from chronicle_client.config import (
16+
REPO_ROOT,
17+
ClientConfig,
18+
load_client_env,
19+
resolve_backend_url,
20+
websocket_url,
21+
)
22+
23+
__all__ = [
24+
"ClientConfig",
25+
"REPO_ROOT",
26+
"acheck_credentials",
27+
"auth_headers",
28+
"bearer_query_param",
29+
"check_credentials",
30+
"describe_key",
31+
"load_client_env",
32+
"resolve_backend_url",
33+
"websocket_url",
34+
]
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Presenting a Chronicle API key, and checking it before we rely on it."""
2+
3+
import logging
4+
from typing import Optional
5+
6+
import httpx
7+
8+
logger = logging.getLogger(__name__)
9+
10+
# Cheapest authenticated endpoint that exists on every backend: it resolves the
11+
# bearer credential to a user and returns nothing expensive.
12+
_WHOAMI_PATH = "/users/me"
13+
14+
15+
def auth_headers(api_key: str) -> dict[str, str]:
16+
"""Authorization header for an API key.
17+
18+
API keys travel on the same header as a JWT, which is what lets clients that
19+
only offer an "API key" field talk to Chronicle unmodified.
20+
"""
21+
return {"Authorization": f"Bearer {api_key}"}
22+
23+
24+
def check_credentials(
25+
api_key: str, backend_url: str, *, timeout: float = 10.0, verify: bool = True
26+
) -> bool:
27+
"""Verify the API key is accepted, synchronously.
28+
29+
Worth doing at startup: without it a bad credential surfaces later as an
30+
opaque WebSocket close on every reconnect attempt, with nothing in the logs
31+
pointing at auth.
32+
"""
33+
if not api_key:
34+
logger.error("CHRONICLE_API_KEY is not set")
35+
return False
36+
try:
37+
with httpx.Client(timeout=timeout, verify=verify) as client:
38+
resp = client.get(
39+
f"{backend_url.rstrip('/')}{_WHOAMI_PATH}", headers=auth_headers(api_key)
40+
)
41+
except httpx.HTTPError as e:
42+
logger.error("Auth error: %s", e)
43+
return False
44+
return _log_outcome(resp.status_code)
45+
46+
47+
async def acheck_credentials(
48+
api_key: str, backend_url: str, *, timeout: float = 10.0, verify: bool = True
49+
) -> bool:
50+
"""Async twin of :func:`check_credentials`, for clients already on asyncio."""
51+
if not api_key:
52+
logger.error("CHRONICLE_API_KEY is not set")
53+
return False
54+
try:
55+
async with httpx.AsyncClient(timeout=timeout, verify=verify) as client:
56+
resp = await client.get(
57+
f"{backend_url.rstrip('/')}{_WHOAMI_PATH}", headers=auth_headers(api_key)
58+
)
59+
except httpx.HTTPError as e:
60+
logger.error("Auth error: %s", e)
61+
return False
62+
return _log_outcome(resp.status_code)
63+
64+
65+
def _log_outcome(status_code: int) -> bool:
66+
if status_code == 200:
67+
logger.info("Auth OK")
68+
return True
69+
if status_code == 401:
70+
logger.error("Auth failed: API key rejected (revoked, expired, or mistyped)")
71+
else:
72+
logger.error("Auth failed: HTTP %d", status_code)
73+
return False
74+
75+
76+
def bearer_query_param(api_key: str) -> str:
77+
"""The ``token=`` query value for endpoints that cannot set headers.
78+
79+
Chronicle's WebSocket and audio URLs accept the credential as a query
80+
parameter; the backend resolves API keys and JWTs through the same strategy,
81+
so a key works wherever a JWT did.
82+
"""
83+
return api_key
84+
85+
86+
def describe_key(api_key: Optional[str]) -> str:
87+
"""Public, log-safe identity of a key: its prefix, never the secret."""
88+
if not api_key:
89+
return "(unset)"
90+
parts = api_key.split("_", 2)
91+
if len(parts) == 3:
92+
return f"{parts[0]}_{parts[1]}_…"
93+
return "(malformed)"
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Where the backend is, and what credential to reach it with.
2+
3+
Every native client (tray, vault sync, wearable client, havpe relay) needs the
4+
same four facts and used to derive them independently — four copies of the
5+
repo-root ``.env`` load, four copies of the discovery fallback, four readings of
6+
``CHRONICLE_API_KEY``. This is the single source.
7+
"""
8+
9+
import logging
10+
import os
11+
import socket
12+
import sys
13+
from dataclasses import dataclass
14+
from pathlib import Path
15+
from typing import Optional
16+
17+
from dotenv import load_dotenv
18+
19+
logger = logging.getLogger(__name__)
20+
21+
# Files that only exist together at the repository root. Used as the marker
22+
# rather than a fixed number of parent hops, because that only holds when the
23+
# package is installed editable — a regular install puts this module in
24+
# site-packages, where counting parents silently yields the wrong directory
25+
# (and a silently-empty API key).
26+
_ROOT_MARKERS = ("discovery.py", "setup_utils.py")
27+
28+
29+
def _looks_like_repo_root(path: Path) -> bool:
30+
return all((path / marker).is_file() for marker in _ROOT_MARKERS)
31+
32+
33+
def _find_repo_root() -> Path:
34+
"""Locate the Chronicle checkout, or fall back to the source-tree layout."""
35+
override = os.getenv("CHRONICLE_REPO_ROOT")
36+
if override:
37+
candidate = Path(override).expanduser().resolve()
38+
if _looks_like_repo_root(candidate):
39+
return candidate
40+
logger.warning("CHRONICLE_REPO_ROOT=%s does not look like a checkout", candidate)
41+
42+
here = Path(__file__).resolve()
43+
cwd = Path.cwd().resolve()
44+
# `Path.parents` excludes the path itself, so include each start directory
45+
# explicitly — the common case is being run from the checkout root.
46+
for start in (here, cwd):
47+
for candidate in (start, *start.parents):
48+
if _looks_like_repo_root(candidate):
49+
return candidate
50+
51+
# extras/chronicle-client/chronicle_client/config.py -> repo root
52+
return here.parents[3]
53+
54+
55+
REPO_ROOT = _find_repo_root()
56+
57+
_env_loaded = False
58+
59+
60+
def load_client_env() -> None:
61+
"""Load the repository-root ``.env`` shared by all native client components.
62+
63+
Idempotent: several sections of the tray call this during startup, and
64+
python-dotenv does not override already-set variables, so repeated calls are
65+
harmless — but skipping the re-read keeps startup logs clean.
66+
"""
67+
global _env_loaded
68+
if _env_loaded:
69+
return
70+
load_dotenv(REPO_ROOT / ".env")
71+
_env_loaded = True
72+
73+
74+
def resolve_backend_url(explicit: Optional[str] = None) -> str:
75+
"""Resolve the backend base URL: explicit value > Tailnet discovery > localhost.
76+
77+
``discovery`` lives at the repository root and is not yet a package, so this
78+
is the one place in client code that has to put the repo root on ``sys.path``.
79+
Keeping it here means no other client module needs to know that.
80+
"""
81+
if explicit is None:
82+
explicit = os.getenv("BACKEND_URL")
83+
84+
root = str(REPO_ROOT)
85+
if root not in sys.path:
86+
sys.path.append(root)
87+
try:
88+
from discovery import resolve_backend_url as _resolve
89+
except ImportError:
90+
logger.warning("discovery module unavailable; set BACKEND_URL in .env")
91+
return explicit or "http://localhost:8000"
92+
return _resolve(explicit, logger=logger)
93+
94+
95+
def websocket_url(backend_url: str) -> str:
96+
"""Derive the WebSocket base URL from an http(s) backend URL."""
97+
return backend_url.replace("https://", "wss://").replace("http://", "ws://")
98+
99+
100+
@dataclass
101+
class ClientConfig:
102+
"""Connection settings shared by every native client."""
103+
104+
backend_url: str
105+
backend_ws_url: str
106+
# Long-lived Chronicle API key (webui → Settings → API Keys). A JWT expires
107+
# after 24h and these clients have no way to log in again, so a key is the
108+
# only credential that survives a long-running session.
109+
api_key: str
110+
device_name: str
111+
verify_ssl: bool = True
112+
113+
@classmethod
114+
def from_env(cls, *, default_device_name: Optional[str] = None) -> "ClientConfig":
115+
load_client_env()
116+
backend_url = resolve_backend_url()
117+
return cls(
118+
backend_url=backend_url,
119+
backend_ws_url=os.getenv("BACKEND_WS_URL") or websocket_url(backend_url),
120+
api_key=os.getenv("CHRONICLE_API_KEY", ""),
121+
device_name=(
122+
os.getenv("DEVICE_NAME")
123+
or default_device_name
124+
or socket.gethostname()
125+
),
126+
verify_ssl=os.getenv("VERIFY_SSL", "true").lower() == "true",
127+
)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[project]
2+
name = "chronicle-client"
3+
version = "0.1.0"
4+
description = "Shared configuration and auth for Chronicle's native client apps (tray, vault sync, wearable client, relays)."
5+
readme = "README.md"
6+
requires-python = ">=3.10"
7+
dependencies = [
8+
"httpx>=0.27.0",
9+
"python-dotenv>=1.0.0",
10+
"minidisc-python>=0.1.0",
11+
]
12+
13+
[build-system]
14+
requires = ["hatchling"]
15+
build-backend = "hatchling.build"
16+
17+
[tool.hatch.build.targets.wheel]
18+
packages = ["chronicle_client"]

‎extras/chronicle-tray/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,6 @@ tray on the new code automatically.
3636
## Section internals
3737

3838
The tray reuses the sibling projects' logic in place rather than duplicating it:
39-
vault sync from `extras/vault-sync` (`vault_core.py`, `syncthing_manager.py`),
40-
pendant BLE from `extras/local-wearable-client` (`ble_manager.py`). Their state
39+
vault sync from the `chronicle-vault-sync` package and pendant BLE from the
40+
`chronicle-wearable` package, both declared as path dependencies. Their state
4141
directories and backend pairing flows are reused unchanged.
Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
1-
"""Sibling-project path bootstrap.
1+
"""Repo-root module bootstrap.
22
3-
The tray reuses the vault-sync and wearable-client code in place (same repo
4-
checkout) rather than packaging them: both are flat-module uv projects, so
5-
they're imported by putting their directories on sys.path.
6-
7-
Ordering matters: both projects have flat ``main``/``service`` modules. Only
8-
the wearable client's are ever imported (via ble_manager), so its directory is
9-
*inserted at the front* while the vault dir is *appended* — ``import main``
10-
must always resolve to the wearable client's.
3+
``clients.py`` and ``discovery.py`` still live at the repository root rather than
4+
in a package, so they have to be reached via ``sys.path``. Everything else the
5+
tray uses — vault sync, the wearable client, shared client config — is a real
6+
dependency declared in ``pyproject.toml`` and imported normally.
117
"""
128

139
import sys
@@ -17,24 +13,9 @@
1713
EXTRAS_DIR = PROJECT_DIR.parent
1814
REPO_ROOT = EXTRAS_DIR.parent
1915

20-
VAULT_SYNC_DIR = EXTRAS_DIR / "vault-sync"
21-
WEARABLE_DIR = EXTRAS_DIR / "local-wearable-client"
22-
2316

2417
def add_repo_root() -> None:
2518
"""Make repo-root modules (clients.py, discovery.py) importable."""
2619
path = str(REPO_ROOT)
2720
if path not in sys.path:
2821
sys.path.append(path)
29-
30-
31-
def add_vault_path() -> None:
32-
path = str(VAULT_SYNC_DIR)
33-
if path not in sys.path:
34-
sys.path.append(path)
35-
36-
37-
def add_wearable_path() -> None:
38-
path = str(WEARABLE_DIR)
39-
if path not in sys.path:
40-
sys.path.insert(0, path)

0 commit comments

Comments
 (0)