diff --git a/light_api/light_api/worker.py b/light_api/light_api/worker.py new file mode 100644 index 0000000..4f2f957 --- /dev/null +++ b/light_api/light_api/worker.py @@ -0,0 +1,132 @@ +"""Run a `Light` session on a dedicated background thread. + +`Light` is not thread-safe, so any consumer with multiple threads must funnel every +call through a single owner. `LightThread` is that owner: `submit(fn)` hands `fn(light)` +to the worker thread and blocks for the result. +""" + +from __future__ import annotations + +import queue +import threading +from concurrent.futures import Future +from dataclasses import dataclass +from typing import Any, Callable + +from light_api.client import Light + + +@dataclass +class LightConfig: + """Everything needed to construct a `Light` session. + + Mirrors `Light.__init__`; unset fields fall back to files/env/keyring exactly as they do there. + """ + + email: str | None = None + email_file: str | None = None + password: str | None = None + password_file: str | None = None + phone: str | None = None + phone_file: str | None = None + device_id: str | None = None + device_id_file: str | None = None + cache_enabled: bool = False + password_prompt: Callable[[], str] | None = None + + +class LightThread: + """Owns a `Light` instance on a background thread. Work is submitted via `submit`.""" + + def __init__(self, config: LightConfig) -> None: + """Initialize the thread.""" + self._config = config + + # queue of work to do + self._queue: queue.Queue[tuple[Callable[[Light], Any], Future[Any]] | None] = ( + queue.Queue() + ) + + self._thread = threading.Thread(target=self._run, daemon=True) + self._ready = threading.Event() + self._error: BaseException | None = None + self._lock = threading.Lock() + self._stopped = False + + def start(self) -> None: + """Start the thread and block until the session finishes its auth attempt. + + Raises: + Whatever the session setup raised (bad credentials, network, ...) + """ + self._thread.start() + self._ready.wait() + if self._error is not None: + raise self._error + + def _run(self) -> None: + try: + with Light( + email=self._config.email, + email_file=self._config.email_file, + password=self._config.password, + password_file=self._config.password_file, + phone=self._config.phone, + phone_file=self._config.phone_file, + device_id=self._config.device_id, + device_id_file=self._config.device_id_file, + cache_enabled=self._config.cache_enabled, + password_prompt=self._config.password_prompt, + ) as light: + self._ready.set() + while True: + item = self._queue.get() + if item is None: + break + func, future = item + try: + future.set_result(func(light)) + except BaseException as e: + future.set_exception(e) + except BaseException as e: + self._error = e + self._ready.set() + finally: + self._fail_pending() + + def _fail_pending(self) -> None: + """Empties queue (fails pending items) when worker is done to prevent callers from hanging.""" + with self._lock: + self._stopped = True + while True: + try: + item = self._queue.get_nowait() + except queue.Empty: + break + + if item is not None: + _, future = item + if not future.done(): + future.set_exception( + RuntimeError("LightThread worker has stopped") + ) + + def submit(self, func: Callable[[Light], Any], timeout: float | None = None) -> Any: + """Run `func(light)` on the worker thread and return its result (blocking). + + Raises: + - RuntimeError if the worker is not running + - TimeoutError if `timeout` elapses first + - Or whatever `func` raised. + """ + future: Future[Any] = Future() + with self._lock: + if self._stopped or not self._thread.is_alive(): + raise RuntimeError("LightThread worker is not running") + self._queue.put((func, future)) + return future.result(timeout) + + def shutdown(self, timeout: float | None = 5.0) -> None: + """Ask the worker to stop and wait (up to `timeout` seconds) for it to exit.""" + self._queue.put(None) + self._thread.join(timeout) diff --git a/light_daemon/README.md b/light_daemon/README.md new file mode 100644 index 0000000..ef14b3d --- /dev/null +++ b/light_daemon/README.md @@ -0,0 +1,12 @@ +# light-phone-daemon + +A local gRPC daemon that holds an authenticated Light Phone session and exposes +it to other processes, so tools in any language can depend on it. + +**Status:** early scaffolding. Not usable - yet! + +## Layout + +- `proto/` - protobuf contract +- `light_daemon/v1/` - generated protobuf / gRPC modules. + - Regenerate with `./scripts/generate.sh` after changing anything under `proto/`. diff --git a/light_daemon/light_daemon/__init__.py b/light_daemon/light_daemon/__init__.py new file mode 100644 index 0000000..43fd747 --- /dev/null +++ b/light_daemon/light_daemon/__init__.py @@ -0,0 +1 @@ +"""Local gRPC daemon for the Light Phone API.""" diff --git a/light_daemon/light_daemon/__main__.py b/light_daemon/light_daemon/__main__.py new file mode 100644 index 0000000..d8bd56a --- /dev/null +++ b/light_daemon/light_daemon/__main__.py @@ -0,0 +1,88 @@ +"""Run the daemon: `python -m light_daemon` / `light-daemon`.""" + +from __future__ import annotations + +import argparse +import sys + +from light_api.worker import LightConfig, LightThread + +from light_daemon.auth import generate_token +from light_daemon.server import serve + + +def build_arg_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="light-daemon", + description="Local gRPC daemon for the Light Phone API (loopback only).", + allow_abbrev=False, + ) + p.add_argument( + "--port", + type=int, + default=0, + help="TCP port on 127.0.0.1 (0 = OS-assigned, printed on start).", + ) + p.add_argument("--email") + p.add_argument("--email-file") + p.add_argument("--password-file") + p.add_argument("--phone-number") + p.add_argument("--phone-number-file") + p.add_argument("--device-id") + p.add_argument("--device-id-file") + p.add_argument( + "--cache", action="store_true", help="Enable local response caching." + ) + p.add_argument( + "--fake", + action="store_true", + help="Serve an in-memory fake session; no credentials needed (dev only).", + ) + p.add_argument( + "--debug", + action="store_true", + help="Expose gRPC server reflection (still token-gated) for grpcurl etc.", + ) + return p + + +def config_from_args(args: argparse.Namespace) -> LightConfig: + return LightConfig( + email=args.email, + email_file=args.email_file, + password_file=args.password_file, + phone=args.phone_number, + phone_file=args.phone_number_file, + device_id=args.device_id, + device_id_file=args.device_id_file, + cache_enabled=args.cache, + ) + + +def main(argv: list[str] | None = None) -> None: + args = build_arg_parser().parse_args(argv) + token = generate_token() + + if args.fake: + from light_daemon.testing import FakeLight, FakePw + + serve( + FakePw(FakeLight()), + port=args.port, + token=token, + enable_reflection=args.debug, + ) + return + + worker = LightThread(config_from_args(args)) + try: + worker.start() + except Exception as e: # bad credentials, network, multi-device ambiguity, ... + print(f"light-daemon: could not start Light session: {e}", file=sys.stderr) + raise SystemExit(1) + + serve(worker, port=args.port, token=token, enable_reflection=args.debug) + + +if __name__ == "__main__": + main() diff --git a/light_daemon/light_daemon/auth.py b/light_daemon/light_daemon/auth.py new file mode 100644 index 0000000..cec8516 --- /dev/null +++ b/light_daemon/light_daemon/auth.py @@ -0,0 +1,68 @@ +"""Per-instance bearer-token auth for the daemon. + +The daemon generates one random token per invocation. Every RPC must carry it as +`authorization: bearer ` metadata. A serverside interceptor rejects the rest +with UNAUTHENTICATED. The token reaches the client via a stdout handshake line. +""" + +from __future__ import annotations + +import json +import secrets + +import grpc + +_SCHEME = "bearer" +_METADATA_KEY = "authorization" +_DENY_MESSAGE = "missing or invalid token" + + +def generate_token() -> str: + """A fresh, high-entropy token for one daemon run.""" + return secrets.token_urlsafe(32) + + +def bearer_metadata(token: str) -> list[tuple[str, str]]: + """Call metadata that a client attaches to every RPC: `metadata=bearer_metadata(tok)`.""" + return [(_METADATA_KEY, f"{_SCHEME} {token}")] + + +def handshake_line(host: str, port: int, token: str) -> str: + """The single JSON line the daemon prints to stdout on start for a parent process to read.""" + return json.dumps({"host": host, "port": port, "token": token}) + + +def _deny_unary(request, context): + context.abort(grpc.StatusCode.UNAUTHENTICATED, _DENY_MESSAGE) + + +def _deny_stream(request, context): + context.abort(grpc.StatusCode.UNAUTHENTICATED, _DENY_MESSAGE) + yield # unreachable (abort raises) - present so this is a generator function + + +def _deny_handler(handler: grpc.RpcMethodHandler) -> grpc.RpcMethodHandler: + """A handler of the same cardinality as `handler` that always aborts.""" + if handler.request_streaming and handler.response_streaming: + return grpc.stream_stream_rpc_method_handler(_deny_stream) + if handler.request_streaming: + return grpc.stream_unary_rpc_method_handler(_deny_unary) + if handler.response_streaming: + return grpc.unary_stream_rpc_method_handler(_deny_stream) + return grpc.unary_unary_rpc_method_handler(_deny_unary) + + +class AuthInterceptor(grpc.ServerInterceptor): + """Reject any call whose `authorization` metadata isn't `bearer `.""" + + def __init__(self, token: str) -> None: + self._expected = f"{_SCHEME} {token}" + + def intercept_service(self, continuation, handler_call_details): + handler = continuation(handler_call_details) + if handler is None: + return None # unknown method - let gRPC return UNIMPLEMENTED + md = dict(handler_call_details.invocation_metadata or ()) + if md.get(_METADATA_KEY) == self._expected: + return handler + return _deny_handler(handler) diff --git a/light_daemon/light_daemon/errors.py b/light_daemon/light_daemon/errors.py new file mode 100644 index 0000000..c3715a3 --- /dev/null +++ b/light_daemon/light_daemon/errors.py @@ -0,0 +1,83 @@ +"""Map API failures to gRPC status codes. + +The API only raises bare RuntimeErrors and httpx errors. + +httpx errors -> DEADLINE_EXCEEDED / UNAVAILABLE. + +For RuntimeErrors: disambiguate based on the error message. +1. trailing HTTP status -> follow _STATUS_MAP (5xx: UNAVAILABLE, 404: NOT_FOUND, ...) +2. auth keywords detected -> UNAUTHENTICATED (only when there's no status) +3. "no found" keyword -> NOT_FOUND +4. else -> FAILED_PRECONDITION + +Anything else that isn't RuntimeError -> INTERNAL. +""" + +from __future__ import annotations + +import re +from contextlib import contextmanager +from typing import Any, Iterator + +import grpc +import httpx + +_AUTH_HINTS = ("credential", "cached session", "log in", "login", "password") +_NOT_FOUND_HINTS = ("no device found", "no tool found", "no installed tool") + +# _ensure_ok(...) -> RuntimeError("Get tracks: 503") +_TRAILING_STATUS = re.compile(r":\s*(\d{3})\s*$") + +_STATUS_MAP = { + 400: grpc.StatusCode.INVALID_ARGUMENT, + 401: grpc.StatusCode.UNAUTHENTICATED, + 403: grpc.StatusCode.PERMISSION_DENIED, + 404: grpc.StatusCode.NOT_FOUND, + 409: grpc.StatusCode.ABORTED, + 429: grpc.StatusCode.UNAVAILABLE, +} + + +def _looks_like_auth(message: str) -> bool: + lowered = message.lower() + return any(hint in lowered for hint in _AUTH_HINTS) + + +def _code_from_http_status(message: str) -> grpc.StatusCode | None: + m = _TRAILING_STATUS.search(message) + if m is None: + return None + http = int(m.group(1)) + if http in _STATUS_MAP: + return _STATUS_MAP[http] + if 500 <= http <= 599: + return grpc.StatusCode.UNAVAILABLE # transient upstream failure; safe to retry + if 400 <= http <= 499: + return grpc.StatusCode.FAILED_PRECONDITION + return None + + +def _runtime_error_code(message: str) -> grpc.StatusCode: + mapped = _code_from_http_status(message) + if mapped is not None: + return mapped + if _looks_like_auth(message): + return grpc.StatusCode.UNAUTHENTICATED + if any(hint in message.lower() for hint in _NOT_FOUND_HINTS): + return grpc.StatusCode.NOT_FOUND + return grpc.StatusCode.FAILED_PRECONDITION + + +@contextmanager +def grpc_errors(context: Any) -> Iterator[None]: + """Run a servicer body; translate any `light_api` failure into `context.abort`.""" + try: + yield + except httpx.TimeoutException as e: + context.abort(grpc.StatusCode.DEADLINE_EXCEEDED, f"Light API timed out: {e}") + except httpx.HTTPError as e: + context.abort(grpc.StatusCode.UNAVAILABLE, f"Light API unreachable: {e}") + except RuntimeError as e: + context.abort(_runtime_error_code(str(e)), str(e)) + except Exception as e: # noqa: BLE001 - last-resort catch-all + context.abort(grpc.StatusCode.INTERNAL, f"{type(e).__name__}: {e}") diff --git a/light_daemon/light_daemon/mapping.py b/light_daemon/light_daemon/mapping.py new file mode 100644 index 0000000..b134754 --- /dev/null +++ b/light_daemon/light_daemon/mapping.py @@ -0,0 +1,17 @@ +"""Converters between `light_api` dataclasses and generated proto messages.""" + +from __future__ import annotations + +from light_api.music import LightTrack + +from light_daemon.v1 import music_pb2 + + +def track_to_proto(track: LightTrack) -> music_pb2.Track: + return music_pb2.Track( + audio_id=track.audio_id, + title=track.title, + artist=track.artist, + album=track.album, + filename=track.filename, + ) diff --git a/light_daemon/light_daemon/server.py b/light_daemon/light_daemon/server.py new file mode 100644 index 0000000..a3773ec --- /dev/null +++ b/light_daemon/light_daemon/server.py @@ -0,0 +1,90 @@ +"""Wire servicers onto a loopback-only gRPC server.""" + +from __future__ import annotations + +import signal +import sys +from concurrent import futures +from typing import Any + +import grpc +from grpc_reflection.v1alpha import reflection + +from light_daemon.auth import AuthInterceptor, handshake_line +from light_daemon.servicers import MusicServicer +from light_daemon.v1 import music_pb2, music_pb2_grpc + +# API work is serialized in LightThread +_MAX_WORKERS = 8 + +# Seconds to let in-flight RPCs finish on shutdown +_SHUTDOWN_GRACE_SECONDS = 5 + + +def build_server( + pw: Any, *, token: str, port: int = 0, enable_reflection: bool = False +) -> tuple[grpc.Server, int]: + """Build (but do not start) a gRPC server bound to `host:port`. + + Args: + pw: Proxy worker + token: Every RPC must carry this as `bearer` auth metadata. + port: Port to assign. '0' allows OS to autoassign it. + enable_reflection: expose gRPC server reflection (still token-gated). Off by default. + + Returns: + Tuple: [0] grpc server instance, and [1] port. + """ + host = "127.0.0.1" + server = grpc.server( + futures.ThreadPoolExecutor(max_workers=_MAX_WORKERS), + interceptors=[AuthInterceptor(token)], + ) + + music_pb2_grpc.add_MusicServiceServicer_to_server(MusicServicer(pw), server) + + if enable_reflection: + reflection.enable_server_reflection( + ( + music_pb2.DESCRIPTOR.services_by_name["MusicService"].full_name, + reflection.SERVICE_NAME, + ), + server, + ) + + bound_port = server.add_insecure_port(f"{host}:{port}") + return server, bound_port + + +def serve( + pw: Any, *, token: str, port: int = 0, enable_reflection: bool = False +) -> None: + """Build, start, and block on a server until interrupted. + + Args: + pw: Proxy worker + token: Bearer token required on every RPC. + port: Port to assign. '0' allows OS to autoassign it. + enable_reflection: expose gRPC server reflection (see `build_server`). + """ + host = "127.0.0.1" + server, bound_port = build_server( + pw, token=token, port=port, enable_reflection=enable_reflection + ) + + def _shutdown(signum, _frame): + # non-blocking: schedules a graceful stop, then wait_for_termination() returns + server.stop(_SHUTDOWN_GRACE_SECONDS) + + signal.signal(signal.SIGINT, _shutdown) # Ctrl+C + signal.signal(signal.SIGTERM, _shutdown) # `kill`, systemd, etc. + + server.start() + + print(handshake_line(host, bound_port, token), flush=True) + print(f"light-daemon listening on {host}:{bound_port}", file=sys.stderr, flush=True) + + try: + server.wait_for_termination() + finally: + pw.shutdown() diff --git a/light_daemon/light_daemon/servicers.py b/light_daemon/light_daemon/servicers.py new file mode 100644 index 0000000..40d39a3 --- /dev/null +++ b/light_daemon/light_daemon/servicers.py @@ -0,0 +1,27 @@ +"""gRPC servicer implementations. + +All handlers share the same shape: pull fields off `request`, hand the real work to +`self._pw.submit(...)`, map the result back to proto messages. +""" + +from __future__ import annotations + +from typing import Any + +from light_daemon.errors import grpc_errors +from light_daemon.mapping import track_to_proto +from light_daemon.v1 import music_pb2, music_pb2_grpc + + +class MusicServicer(music_pb2_grpc.MusicServiceServicer): + def __init__(self, pw: Any) -> None: + self._pw = pw + + def ListTracks( + self, request: music_pb2.ListTracksRequest, context: Any + ) -> music_pb2.ListTracksResponse: + with grpc_errors(context): + tracks = self._pw.submit(lambda light: light.music.get_tracks()) + return music_pb2.ListTracksResponse( + tracks=[track_to_proto(t) for t in tracks] + ) diff --git a/light_daemon/light_daemon/testing.py b/light_daemon/light_daemon/testing.py new file mode 100644 index 0000000..8a76f6f --- /dev/null +++ b/light_daemon/light_daemon/testing.py @@ -0,0 +1,85 @@ +"""In-memory fakes for developing and testing the daemon without a real session. + +`FakePw` matches the `.submit(fn)` shape of `light_api`'s worker thread, so it +drops in wherever the real one goes. `FakeLight` mimics enough of `light_api`'s +`Light` surface for the servicers to call. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +from light_api.music import LightTrack + +_SAMPLE_TRACKS = [ + LightTrack( + playlist_item_id="pi-1", + playlist_id="pl-1", + audio_id="aud-1", + title="Playing God", + artist="Polyphia", + album="Remember That You Will Die", + filename="01 Playing God.mp3", + ), + LightTrack( + playlist_item_id="pi-2", + playlist_id="pl-1", + audio_id="aud-2", + title="Ego Death", + artist="Polyphia", + album="Remember That You Will Die", + filename="09 Ego Death.mp3", + ), + LightTrack( + playlist_item_id="pi-3", + playlist_id="pl-1", + audio_id="aud-3", + title="Blackwater Park", + artist="Opeth", + album="Blackwater Park", + filename="blackwater_park.flac", + ), +] + + +class _FakeMusic: + def __init__( + self, tracks: list[LightTrack], raises: BaseException | None = None + ) -> None: + self._tracks = tracks + self._raises = raises + + def get_tracks(self) -> list[LightTrack]: + if self._raises is not None: + raise self._raises + return list(self._tracks) + + +class FakeLight: + """Stand-in for `light_api.client.Light`. + + Pass `raises=` to make `music.get_tracks()` fail, for error-mapping tests. + """ + + def __init__( + self, + tracks: list[LightTrack] | None = None, + raises: BaseException | None = None, + ) -> None: + self.music = _FakeMusic( + _SAMPLE_TRACKS if tracks is None else tracks, raises=raises + ) + + +@dataclass +class FakePw: + """Stand-in for the background worker: runs the callable inline.""" + + light: Any + + def submit(self, func: Callable[[Any], Any]) -> Any: + return func(self.light) + + def shutdown(self) -> None: # parity with the real worker + pass diff --git a/light_daemon/light_daemon/v1/__init__.py b/light_daemon/light_daemon/v1/__init__.py new file mode 100644 index 0000000..1d938f0 --- /dev/null +++ b/light_daemon/light_daemon/v1/__init__.py @@ -0,0 +1,4 @@ +"""Generated protobuf / gRPC modules for the light.v1 service surface. + +Regenerate with scripts/generate.sh after editing anything under proto/. +""" diff --git a/light_daemon/light_daemon/v1/music_pb2.py b/light_daemon/light_daemon/v1/music_pb2.py new file mode 100644 index 0000000..6fabc52 --- /dev/null +++ b/light_daemon/light_daemon/v1/music_pb2.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: light_daemon/v1/music.proto +# Protobuf Python Version: 7.35.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 7, + 35, + 1, + '', + 'light_daemon/v1/music.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1blight_daemon/v1/music.proto\x12\x0flight_daemon.v1\"Y\n\x05Track\x12\x10\n\x08\x61udio_id\x18\x01 \x01(\t\x12\r\n\x05title\x18\x02 \x01(\t\x12\x0e\n\x06\x61rtist\x18\x03 \x01(\t\x12\r\n\x05\x61lbum\x18\x04 \x01(\t\x12\x10\n\x08\x66ilename\x18\x05 \x01(\t\"\x13\n\x11ListTracksRequest\"<\n\x12ListTracksResponse\x12&\n\x06tracks\x18\x01 \x03(\x0b\x32\x16.light_daemon.v1.Track2e\n\x0cMusicService\x12U\n\nListTracks\x12\".light_daemon.v1.ListTracksRequest\x1a#.light_daemon.v1.ListTracksResponseb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'light_daemon.v1.music_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_TRACK']._serialized_start=48 + _globals['_TRACK']._serialized_end=137 + _globals['_LISTTRACKSREQUEST']._serialized_start=139 + _globals['_LISTTRACKSREQUEST']._serialized_end=158 + _globals['_LISTTRACKSRESPONSE']._serialized_start=160 + _globals['_LISTTRACKSRESPONSE']._serialized_end=220 + _globals['_MUSICSERVICE']._serialized_start=222 + _globals['_MUSICSERVICE']._serialized_end=323 +# @@protoc_insertion_point(module_scope) diff --git a/light_daemon/light_daemon/v1/music_pb2.pyi b/light_daemon/light_daemon/v1/music_pb2.pyi new file mode 100644 index 0000000..ccc9cf1 --- /dev/null +++ b/light_daemon/light_daemon/v1/music_pb2.pyi @@ -0,0 +1,31 @@ +from google.protobuf.internal import containers as _containers +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class Track(_message.Message): + __slots__ = ("audio_id", "title", "artist", "album", "filename") + AUDIO_ID_FIELD_NUMBER: _ClassVar[int] + TITLE_FIELD_NUMBER: _ClassVar[int] + ARTIST_FIELD_NUMBER: _ClassVar[int] + ALBUM_FIELD_NUMBER: _ClassVar[int] + FILENAME_FIELD_NUMBER: _ClassVar[int] + audio_id: str + title: str + artist: str + album: str + filename: str + def __init__(self, audio_id: _Optional[str] = ..., title: _Optional[str] = ..., artist: _Optional[str] = ..., album: _Optional[str] = ..., filename: _Optional[str] = ...) -> None: ... + +class ListTracksRequest(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class ListTracksResponse(_message.Message): + __slots__ = ("tracks",) + TRACKS_FIELD_NUMBER: _ClassVar[int] + tracks: _containers.RepeatedCompositeFieldContainer[Track] + def __init__(self, tracks: _Optional[_Iterable[_Union[Track, _Mapping]]] = ...) -> None: ... diff --git a/light_daemon/light_daemon/v1/music_pb2_grpc.py b/light_daemon/light_daemon/v1/music_pb2_grpc.py new file mode 100644 index 0000000..595eadf --- /dev/null +++ b/light_daemon/light_daemon/v1/music_pb2_grpc.py @@ -0,0 +1,98 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + +from light_daemon.v1 import music_pb2 as light__daemon_dot_v1_dot_music__pb2 + +GRPC_GENERATED_VERSION = '1.83.1' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in light_daemon/v1/music_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) + + +class MusicServiceStub: + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.ListTracks = channel.unary_unary( + '/light_daemon.v1.MusicService/ListTracks', + request_serializer=light__daemon_dot_v1_dot_music__pb2.ListTracksRequest.SerializeToString, + response_deserializer=light__daemon_dot_v1_dot_music__pb2.ListTracksResponse.FromString, + _registered_method=True) + + +class MusicServiceServicer: + """Missing associated documentation comment in .proto file.""" + + def ListTracks(self, request, context): + """Return every track currently on the device. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_MusicServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'ListTracks': grpc.unary_unary_rpc_method_handler( + servicer.ListTracks, + request_deserializer=light__daemon_dot_v1_dot_music__pb2.ListTracksRequest.FromString, + response_serializer=light__daemon_dot_v1_dot_music__pb2.ListTracksResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'light_daemon.v1.MusicService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('light_daemon.v1.MusicService', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class MusicService: + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def ListTracks(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/light_daemon.v1.MusicService/ListTracks', + light__daemon_dot_v1_dot_music__pb2.ListTracksRequest.SerializeToString, + light__daemon_dot_v1_dot_music__pb2.ListTracksResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/light_daemon/proto/light_daemon/v1/music.proto b/light_daemon/proto/light_daemon/v1/music.proto new file mode 100644 index 0000000..7a60891 --- /dev/null +++ b/light_daemon/proto/light_daemon/v1/music.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package light_daemon.v1; + +message Track { + string audio_id = 1; + string title = 2; + string artist = 3; + string album = 4; + string filename = 5; +} + +message ListTracksRequest {} + +message ListTracksResponse { + repeated Track tracks = 1; +} + +service MusicService { + // Return every track currently on the device. + rpc ListTracks(ListTracksRequest) returns (ListTracksResponse); +} diff --git a/light_daemon/pyproject.toml b/light_daemon/pyproject.toml new file mode 100644 index 0000000..1845d46 --- /dev/null +++ b/light_daemon/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "light-phone-daemon" +version = "0.6.0" +description = "Local gRPC daemon exposing the Light Phone API to other processes" +authors = [{ name = "Alexis Garado", email = "alexisgarado@proton.me" }] +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "light-phone-api>=0.6.0", + "grpcio>=1.83.1", + "grpcio-reflection>=1.83.1", + "protobuf>=7.35.1", +] + +[project.scripts] +light-daemon = "light_daemon.__main__:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["light_daemon"] diff --git a/light_daemon/scripts/generate.sh b/light_daemon/scripts/generate.sh new file mode 100755 index 0000000..6ef64ce --- /dev/null +++ b/light_daemon/scripts/generate.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Regenerate the Python protobuf / gRPC modules from proto/. +# +# Output lands in light_daemon/v1/ (e.g. light_daemon/v1/music_pb2.py). +# +# Run from the light_daemon/ directory: ./scripts/generate.sh + +set -euo pipefail + +cd "$(dirname "$0")/.." + +python -m grpc_tools.protoc \ + -I proto \ + --python_out=. \ + --grpc_python_out=. \ + --pyi_out=. \ + proto/light_daemon/v1/*.proto + +echo "generated:" +ls -1 light_daemon/v1/*_pb2*.py* 2>/dev/null || true diff --git a/pyproject.toml b/pyproject.toml index 20592e6..0c57ebb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,10 @@ [tool.uv.workspace] -members = ["light_api", "light_cli_tui"] +members = ["light_api", "light_cli_tui", "light_daemon"] [tool.uv.sources] light_api = { workspace = true } light-phone-api = { workspace = true } +light-phone-daemon = { workspace = true } [dependency-groups] dev = [ @@ -11,4 +12,5 @@ dev = [ "pytest-cov", "respx", "jsonschema", + "grpcio-tools>=1.83.1", ] diff --git a/tests/test_daemon_auth.py b/tests/test_daemon_auth.py new file mode 100644 index 0000000..248e239 --- /dev/null +++ b/tests/test_daemon_auth.py @@ -0,0 +1,65 @@ +"""Auth interceptor: a token-guarded server rejects calls without the token.""" + +import grpc +import pytest + +from light_daemon.auth import bearer_metadata, generate_token, handshake_line +from light_daemon.server import build_server +from light_daemon.testing import FakeLight, FakePw +from light_daemon.v1 import music_pb2, music_pb2_grpc + +_TOKEN = "test-token-123" + + +@pytest.fixture +def channel(): + server, port = build_server(FakePw(FakeLight()), token=_TOKEN) + server.start() + chan = grpc.insecure_channel(f"127.0.0.1:{port}") + try: + yield chan + finally: + chan.close() + server.stop(grace=None) + + +def _call(channel, metadata=None): + stub = music_pb2_grpc.MusicServiceStub(channel) + return stub.ListTracks(music_pb2.ListTracksRequest(), metadata=metadata) + + +def test_missing_token_is_unauthenticated(channel): + with pytest.raises(grpc.RpcError) as exc: + _call(channel) + assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED + + +def test_wrong_token_is_unauthenticated(channel): + with pytest.raises(grpc.RpcError) as exc: + _call(channel, bearer_metadata("not-the-token")) + assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED + + +def test_correct_token_passes(channel): + resp = _call(channel, bearer_metadata(_TOKEN)) + assert [t.title for t in resp.tracks] == [ + "Playing God", + "Ego Death", + "Blackwater Park", + ] + + +def test_build_server_requires_a_token(): + with pytest.raises(TypeError): + build_server(FakePw(FakeLight())) + + +def test_token_helpers(): + tok = generate_token() + assert len(tok) >= 32 + assert bearer_metadata(tok) == [("authorization", f"bearer {tok}")] + + import json + + line = json.loads(handshake_line("127.0.0.1", 44227, tok)) + assert line == {"host": "127.0.0.1", "port": 44227, "token": tok} diff --git a/tests/test_daemon_errors.py b/tests/test_daemon_errors.py new file mode 100644 index 0000000..852c1fa --- /dev/null +++ b/tests/test_daemon_errors.py @@ -0,0 +1,67 @@ +"""Ensure API failures surface as sensible gRPC status codes, not UNKNOWN.""" + +import grpc +import httpx +import pytest + +from light_daemon.auth import bearer_metadata +from light_daemon.server import build_server +from light_daemon.testing import FakeLight, FakePw +from light_daemon.v1 import music_pb2, music_pb2_grpc + +_TOKEN = "err-test-token" +_MD = bearer_metadata(_TOKEN) + + +def _list_tracks_with(raises): + server, port = build_server(FakePw(FakeLight(raises=raises)), token=_TOKEN) + server.start() + try: + with grpc.insecure_channel(f"127.0.0.1:{port}") as chan: + stub = music_pb2_grpc.MusicServiceStub(chan) + with pytest.raises(grpc.RpcError) as exc: + stub.ListTracks(music_pb2.ListTracksRequest(), metadata=_MD) + return exc.value + finally: + server.stop(grace=None) + + +@pytest.mark.parametrize( + "raises, expected", + [ + # transport + (httpx.TimeoutException("slow"), grpc.StatusCode.DEADLINE_EXCEEDED), + (httpx.ConnectError("refused"), grpc.StatusCode.UNAVAILABLE), + # _ensure_ok(": ") - the trailing HTTP code drives it + (RuntimeError("Get tracks: 503"), grpc.StatusCode.UNAVAILABLE), + (RuntimeError("Get tracks: 500"), grpc.StatusCode.UNAVAILABLE), + (RuntimeError("Get tracks: 429"), grpc.StatusCode.UNAVAILABLE), + (RuntimeError("Update metadata: 404"), grpc.StatusCode.NOT_FOUND), + (RuntimeError("Delete track: 401"), grpc.StatusCode.UNAUTHENTICATED), + # explicit status wins over the auth keyword + (RuntimeError("Login failed: 500"), grpc.StatusCode.UNAVAILABLE), + (RuntimeError("Login failed: 401"), grpc.StatusCode.UNAUTHENTICATED), + (RuntimeError("Patch item: 403"), grpc.StatusCode.PERMISSION_DENIED), + (RuntimeError("Post audio: 400"), grpc.StatusCode.INVALID_ARGUMENT), + (RuntimeError("Weird one: 418"), grpc.StatusCode.FAILED_PRECONDITION), + # keyword-sniffed cases + ( + RuntimeError("No cached session and no credentials available."), + grpc.StatusCode.UNAUTHENTICATED, + ), + ( + RuntimeError("No tool found matching 'xyz'"), + grpc.StatusCode.NOT_FOUND, + ), + ( + RuntimeError("Multiple devices found - specify one via --device-id"), + grpc.StatusCode.FAILED_PRECONDITION, + ), + # non-RuntimeError + (ValueError("something odd"), grpc.StatusCode.INTERNAL), + ], +) +def test_error_mapping(raises, expected): + err = _list_tracks_with(raises) + assert err.code() == expected + assert err.details() # original message is carried through, not swallowed diff --git a/tests/test_daemon_gen.py b/tests/test_daemon_gen.py new file mode 100644 index 0000000..3b40844 --- /dev/null +++ b/tests/test_daemon_gen.py @@ -0,0 +1,38 @@ +"""Smoke tests for the generated light_daemon protobuf/gRPC modules.""" + +import grpc + +from light_daemon.v1 import music_pb2, music_pb2_grpc + + +def test_track_message_roundtrips(): + track = music_pb2.Track(audio_id="abc", title="Playing God", artist="Polyphia") + restored = music_pb2.Track.FromString(track.SerializeToString()) + assert restored.audio_id == "abc" + assert restored.title == "Playing God" + assert restored.artist == "Polyphia" + # an unset scalar is indistinguishable from its zero value + assert restored.album == "" + assert restored.filename == "" + + +def test_repeated_tracks_behaves_like_a_list(): + resp = music_pb2.ListTracksResponse() + resp.tracks.add(title="a") + resp.tracks.add(title="b") + assert [t.title for t in resp.tracks] == ["a", "b"] + assert len(resp.tracks) == 2 + + restored = music_pb2.ListTracksResponse.FromString(resp.SerializeToString()) + assert [t.title for t in restored.tracks] == ["a", "b"] + + +def test_service_surface(): + # servicer: ListTracks is a plain (class) method to override + assert callable(music_pb2_grpc.MusicServiceServicer.ListTracks) + # registration helper the server bootstrap will use + assert hasattr(music_pb2_grpc, "add_MusicServiceServicer_to_server") + # stub: the RPC callable is bound in __init__ from the channel + with grpc.insecure_channel("localhost:1") as channel: + stub = music_pb2_grpc.MusicServiceStub(channel) + assert callable(stub.ListTracks) diff --git a/tests/test_daemon_main.py b/tests/test_daemon_main.py new file mode 100644 index 0000000..ce7bf2d --- /dev/null +++ b/tests/test_daemon_main.py @@ -0,0 +1,58 @@ +"""Argument parsing/config mapping for the daemon entrypoint. + +The `serve()` and real `LightThread` paths aren't exercised here since they block and need credentials. +""" + +import pytest + +from light_daemon.__main__ import build_arg_parser, config_from_args + + +def test_config_from_args_maps_every_flag(): + args = build_arg_parser().parse_args( + [ + "--email", + "a@b.c", + "--email-file", + "/e", + "--password-file", + "/pw", + "--phone-number", + "5551234567", + "--phone-number-file", + "/pn", + "--device-id", + "dev-1", + "--device-id-file", + "/di", + "--cache", + ] + ) + cfg = config_from_args(args) + + assert cfg.email == "a@b.c" + assert cfg.email_file == "/e" + assert cfg.password_file == "/pw" + assert cfg.phone == "5551234567" # --phone-number -> LightConfig.phone + assert cfg.phone_file == "/pn" + assert cfg.device_id == "dev-1" + assert cfg.device_id_file == "/di" + assert cfg.cache_enabled is True + + +def test_defaults_are_all_unset(): + args = build_arg_parser().parse_args([]) + cfg = config_from_args(args) + + assert (cfg.email, cfg.email_file, cfg.password_file) == (None, None, None) + assert (cfg.phone, cfg.phone_file) == (None, None) + assert (cfg.device_id, cfg.device_id_file) == (None, None) + assert cfg.cache_enabled is False + assert args.port == 0 + assert args.fake is False + + +def test_no_password_flag(): + # the daemon never takes a plaintext password on the command line + with pytest.raises(SystemExit): + build_arg_parser().parse_args(["--password", "hunter2"]) diff --git a/tests/test_daemon_reflection.py b/tests/test_daemon_reflection.py new file mode 100644 index 0000000..9079446 --- /dev/null +++ b/tests/test_daemon_reflection.py @@ -0,0 +1,60 @@ +"""Server reflection is off by default, on with `enable_reflection`, and always token-gated.""" + +import grpc +import pytest +from grpc_reflection.v1alpha import reflection_pb2, reflection_pb2_grpc + +from light_daemon.auth import bearer_metadata +from light_daemon.server import build_server +from light_daemon.testing import FakeLight, FakePw + +_TOKEN = "reflect-test-token" +_MD = bearer_metadata(_TOKEN) + + +def _list_services(channel, metadata): + stub = reflection_pb2_grpc.ServerReflectionStub(channel) + req = reflection_pb2.ServerReflectionRequest(list_services="") + resp = next(stub.ServerReflectionInfo(iter([req]), metadata=metadata)) + return {s.name for s in resp.list_services_response.service} + + +def _server(*, enable_reflection): + return build_server( + FakePw(FakeLight()), token=_TOKEN, enable_reflection=enable_reflection + ) + + +def test_reflection_off_by_default(): + server, port = _server(enable_reflection=False) + server.start() + try: + with grpc.insecure_channel(f"127.0.0.1:{port}") as chan: + with pytest.raises(grpc.RpcError) as exc: + _list_services(chan, _MD) + assert exc.value.code() == grpc.StatusCode.UNIMPLEMENTED + finally: + server.stop(grace=None) + + +def test_reflection_lists_the_service_when_enabled(): + server, port = _server(enable_reflection=True) + server.start() + try: + with grpc.insecure_channel(f"127.0.0.1:{port}") as chan: + names = _list_services(chan, _MD) + assert "light_daemon.v1.MusicService" in names + finally: + server.stop(grace=None) + + +def test_reflection_still_needs_the_token(): + server, port = _server(enable_reflection=True) + server.start() + try: + with grpc.insecure_channel(f"127.0.0.1:{port}") as chan: + with pytest.raises(grpc.RpcError) as exc: + _list_services(chan, metadata=None) + assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED + finally: + server.stop(grace=None) diff --git a/tests/test_daemon_server.py b/tests/test_daemon_server.py new file mode 100644 index 0000000..f7ced9d --- /dev/null +++ b/tests/test_daemon_server.py @@ -0,0 +1,59 @@ +"""End-to-end slice: real gRPC server + real generated stub + fake session.""" + +import grpc +import pytest + +from light_daemon.auth import bearer_metadata +from light_daemon.server import build_server +from light_daemon.testing import FakeLight, FakePw +from light_daemon.v1 import music_pb2, music_pb2_grpc + +_TOKEN = "wiring-test-token" +_MD = bearer_metadata(_TOKEN) + + +@pytest.fixture +def channel(): + server, port = build_server(FakePw(FakeLight()), token=_TOKEN) + server.start() + chan = grpc.insecure_channel(f"127.0.0.1:{port}") + try: + yield chan + finally: + chan.close() + server.stop(grace=None) + + +def test_list_tracks_returns_the_fake_library(channel): + stub = music_pb2_grpc.MusicServiceStub(channel) + resp = stub.ListTracks(music_pb2.ListTracksRequest(), metadata=_MD) + + assert [t.title for t in resp.tracks] == [ + "Playing God", + "Ego Death", + "Blackwater Park", + ] + first = resp.tracks[0] + assert first.audio_id == "aud-1" + assert first.artist == "Polyphia" + assert first.album == "Remember That You Will Die" + assert first.filename == "01 Playing God.mp3" + + +def test_server_gets_an_os_assigned_port(): + server, port = build_server(FakePw(FakeLight()), token=_TOKEN) + try: + assert port != 0 + finally: + server.stop(grace=None) + + +def test_unknown_method_is_unimplemented(channel): + bogus = channel.unary_unary( + "/light_daemon.v1.MusicService/DoesNotExist", + request_serializer=music_pb2.ListTracksRequest.SerializeToString, + response_deserializer=music_pb2.ListTracksResponse.FromString, + ) + with pytest.raises(grpc.RpcError) as exc: + bogus(music_pb2.ListTracksRequest(), metadata=_MD) + assert exc.value.code() == grpc.StatusCode.UNIMPLEMENTED diff --git a/tests/test_daemon_shutdown.py b/tests/test_daemon_shutdown.py new file mode 100644 index 0000000..9cce11c --- /dev/null +++ b/tests/test_daemon_shutdown.py @@ -0,0 +1,64 @@ +"""Test that the daemon shuts down cleanly on SIGTERM/SIGINT.""" + +import json +import queue +import signal +import subprocess +import sys +import threading + +import pytest + +_HANDSHAKE_TIMEOUT = 10 +_EXIT_TIMEOUT = 10 + + +def _read_handshake(proc: subprocess.Popen) -> dict: + """Read the one handshake line with a bounded wait. + + Fail loudly with the child's stderr + exit status if it never comes. + """ + result: queue.Queue[str] = queue.Queue(maxsize=1) + threading.Thread( + target=lambda: result.put(proc.stdout.readline()), daemon=True + ).start() + + try: + line = result.get(timeout=_HANDSHAKE_TIMEOUT) + except queue.Empty: + proc.kill() + raise AssertionError( + f"daemon printed no handshake within {_HANDSHAKE_TIMEOUT}s " + f"(still running={proc.poll() is None})\n" + f"stderr:\n{proc.stderr.read()}" + ) + + if not line: # EOF - child exited before printing + raise AssertionError( + f"daemon exited before handshake (exit={proc.wait(2)})\n" + f"stderr:\n{proc.stderr.read()}" + ) + return json.loads(line) + + +@pytest.mark.parametrize("sig", [signal.SIGTERM, signal.SIGINT]) +def test_shuts_down_cleanly_on_signal(sig): + proc = subprocess.Popen( + [sys.executable, "-m", "light_daemon", "--fake", "--port", "0"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + handshake = _read_handshake(proc) + assert {"host", "port", "token"} <= handshake.keys() + + proc.send_signal(sig) + code = proc.wait(timeout=_EXIT_TIMEOUT) + assert ( + code == 0 + ), f"expected clean exit, got {code}\nstderr:\n{proc.stderr.read()}" + finally: + if proc.poll() is None: + proc.kill() + proc.wait(timeout=5) diff --git a/tests/test_worker.py b/tests/test_worker.py new file mode 100644 index 0000000..6fbed53 --- /dev/null +++ b/tests/test_worker.py @@ -0,0 +1,94 @@ +"""LightThread: a failing / slow / post-shutdown call must never wedge submit().""" + +import time +from concurrent.futures import Future + +import pytest + +from light_api.worker import LightConfig, LightThread + + +class _FakeLightCM: + """Stand-in for `Light` as a context manager - no network, no auth.""" + + def __init__(self, **_kwargs): + pass + + def __enter__(self): + return object() + + def __exit__(self, *_exc): + return False + + +@pytest.fixture +def worker(monkeypatch): + monkeypatch.setattr("light_api.worker.Light", _FakeLightCM) + t = LightThread(LightConfig()) + t.start() + try: + yield t + finally: + t.shutdown() + + +def _raise(exc): + def _fn(_light): + raise exc + + return _fn + + +def test_exception_in_task_is_delivered_not_hung(worker): + with pytest.raises(ValueError, match="boom"): + worker.submit(_raise(ValueError("boom"))) + # worker survived and still serves + assert worker.submit(lambda _l: 42) == 42 + + +def test_base_exception_in_task_does_not_kill_the_worker(worker): + with pytest.raises(SystemExit): + worker.submit(_raise(SystemExit("stop"))) + assert worker.submit(lambda _l: "still here") == "still here" + + +def test_timeout_raises_instead_of_blocking_forever(worker): + with pytest.raises(TimeoutError): + worker.submit(lambda _l: time.sleep(2), timeout=0.05) + + +def test_submit_after_shutdown_raises(monkeypatch): + monkeypatch.setattr("light_api.worker.Light", _FakeLightCM) + t = LightThread(LightConfig()) + t.start() + t.shutdown() + with pytest.raises(RuntimeError, match="not running"): + t.submit(lambda _l: 1) + + +def test_queued_calls_are_failed_when_the_worker_stops(monkeypatch): + monkeypatch.setattr("light_api.worker.Light", _FakeLightCM) + # never started: nothing consumes the queue, so _fail_pending() is deterministic + t = LightThread(LightConfig()) + + pending: Future = Future() + t._queue.put((lambda _l: 1, pending)) # a call that was queued but never run + t._fail_pending() # worker exit path + + with pytest.raises(RuntimeError, match="stopped"): + pending.result(timeout=1) + with pytest.raises(RuntimeError, match="not running"): + t.submit(lambda _l: 1) + + +def test_failed_session_setup_surfaces_and_does_not_hang(monkeypatch): + class _Boom: + def __init__(self, **_kw): + raise RuntimeError("login failed") + + monkeypatch.setattr("light_api.worker.Light", _Boom) + t = LightThread(LightConfig()) + with pytest.raises(RuntimeError, match="login failed"): + t.start() + with pytest.raises(RuntimeError, match="not running"): + t.submit(lambda _l: 1) diff --git a/uv.lock b/uv.lock index 07b5b8d..8371209 100644 --- a/uv.lock +++ b/uv.lock @@ -6,10 +6,12 @@ requires-python = ">=3.11" members = [ "light-phone-api", "light-phone-cli-tui", + "light-phone-daemon", ] [manifest.dependency-groups] dev = [ + { name = "grpcio-tools", specifier = ">=1.83.1" }, { name = "jsonschema" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -310,6 +312,123 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" }, ] +[[package]] +name = "grpcio" +version = "1.83.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/b1/46539f5050d7c316a13396d185451f95084a74ddc68b12d818595bef0377/grpcio-1.83.1.tar.gz", hash = "sha256:9cee6fcbf2eb57c4b49451787bfa87be8efc1ca02a0b327dd4b54d44502e362b", size = 13445033, upload-time = "2026-08-28T07:09:11.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/e8/2a69fe506c992fc4b02ede5a6255a4b19b7922527d7f0ab2229695463fc1/grpcio-1.83.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:907a5e5afb31f7a46376afc1a1edddd7afa00a74bbbc5b78979bbc34479581f6", size = 6340700, upload-time = "2026-08-28T07:07:49.232Z" }, + { url = "https://files.pythonhosted.org/packages/7f/56/6628e935ca7c5b9270810dd1cb61e5d2ea53eb7d26c62d2987bfef46e022/grpcio-1.83.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:547645f02499c972f3edec9be4db9997f1d03df307c1c199772342ed6d8b3c6d", size = 12183471, upload-time = "2026-08-28T07:07:51.18Z" }, + { url = "https://files.pythonhosted.org/packages/14/c9/f748ae4bd2120c91cf07e7a74cfd3ceac0e36b06d082cd3db802163a372a/grpcio-1.83.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:34f1841fc6d1d76f8a2d74177eafa2d1ec7d7e039633488c9fcc1b375a1fc165", size = 6924669, upload-time = "2026-08-28T07:07:53.223Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d1/797b72d87b0ad8cce15fb4ad247472054655bce94bf64027b2285d7c3666/grpcio-1.83.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:05ba265193fbd9f63355311ec7567bba32a72aeb8e9fd7b3443e4fcad87b0750", size = 7654706, upload-time = "2026-08-28T07:07:54.931Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bf/7f0850aa13d98bb4dd8e7633b6beb639e6fd80a8c0813ad36dec3240f70e/grpcio-1.83.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5cce1d9fe2887239f054dc9c314597e04f33d2e6bd3150a91c4946d7e5be5d98", size = 7083816, upload-time = "2026-08-28T07:07:56.867Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8a/5047fc4041cb6499d836001b4ac2ced0b00aecd2fc6d88e81993066ebbf6/grpcio-1.83.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f732feb060ef57c1a040c24cee072ba9fab99bd0a7d2c916ef3f1c4d84b98974", size = 7607412, upload-time = "2026-08-28T07:07:58.654Z" }, + { url = "https://files.pythonhosted.org/packages/cb/83/b397786f79323c2c127733d6310a5dbe3898333bc5d1153785dbdbcfce9b/grpcio-1.83.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:145b0050d24eb38accd9dc7ae09a3c09b8e7330159f3cfb46b1dba8711d50c42", size = 8643027, upload-time = "2026-08-28T07:08:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3c/ac8ca4521760c8c5876af2f284109c3f826d3ba9836b2226c76ba06258ee/grpcio-1.83.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e844cdb25c3c93c7572e0a37137c12305efea493be4eb65801b3ee93f180c186", size = 8010270, upload-time = "2026-08-28T07:08:02.949Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/ec8680e53ead511e8e3e6efb98771b32b96ea7a0c6a3ad23ffe85a8f83d3/grpcio-1.83.1-cp311-cp311-win32.whl", hash = "sha256:0d07661944477517b12a239e18720c8d9038f80a62f2c56260fae80327f43d2a", size = 4405295, upload-time = "2026-08-28T07:08:04.961Z" }, + { url = "https://files.pythonhosted.org/packages/6f/77/c169e2cee593c49399273912bacab20e50422489c0f884c1e3ae95a1af08/grpcio-1.83.1-cp311-cp311-win_amd64.whl", hash = "sha256:e572da3e247b28a98f46636d33c756e81ffb0f5def96c231ba45332333060595", size = 5166265, upload-time = "2026-08-28T07:08:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/85/9e/a3ba13e08bbee5bf6e57597dfe4823961fd7e94c0b8afe3a4bb7dca639f3/grpcio-1.83.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:5acd14c6ddf047de62cbf8745b11103ea91abbf57d1b8edd5395ccd9fcd13abb", size = 6303170, upload-time = "2026-08-28T07:08:08.188Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ae/65ce56a2527faa17d02cba4c2231c74047ad898be339486ba87f093bfb66/grpcio-1.83.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:16138031a47b771860a16a975b53087f4fd5bbdbb2c03a188c5d90ad65d2bdae", size = 12165806, upload-time = "2026-08-28T07:08:10.309Z" }, + { url = "https://files.pythonhosted.org/packages/4e/91/40432480088a2243d360864de072ed5b78c4ebbaabd29c28918f1e1b1454/grpcio-1.83.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5ccc26715fd4defca5e129e280dd883b1737b65045ec50ffe22ce42104089519", size = 6872490, upload-time = "2026-08-28T07:08:12.355Z" }, + { url = "https://files.pythonhosted.org/packages/c8/62/3da2300c8c79fd20a78a8a4bb6251e5068d9af33bc8fd389b98fec35e8a3/grpcio-1.83.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b74f2a1d9ab1dfa3e263ef33d581613679b78d0884babf11671af26e45570ead", size = 7618367, upload-time = "2026-08-28T07:08:14.025Z" }, + { url = "https://files.pythonhosted.org/packages/bc/19/9fc702e31a631262d7a752fa699f6022821e707fefc8bff49b1550a57729/grpcio-1.83.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72578aa07a4008f17521ef52debcc3acfd1e2c5426243bc3ffb56a38bfe610b7", size = 7040936, upload-time = "2026-08-28T07:08:15.963Z" }, + { url = "https://files.pythonhosted.org/packages/ec/56/95933cc44cba2429765fa065c951dd529e5771b119d9d2481b4646f1d6a5/grpcio-1.83.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c12e1fc59c6dc26d10d9144453ddc6cbfe4cd4c31e874ed2d0132f88e685eb8b", size = 7573096, upload-time = "2026-08-28T07:08:17.729Z" }, + { url = "https://files.pythonhosted.org/packages/ac/80/af63359da06b016de48cb111f144703a10043850dafa43ae0a038907b9e8/grpcio-1.83.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4910b62f7d12197160bfb7de06d876d64dd12d43483e8292f98f49ca09b628d9", size = 8609442, upload-time = "2026-08-28T07:08:19.777Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fa/f0586c56bdfb8a7a2adda01e0ac2413447cde3141ab09411a5d5afdcffd3/grpcio-1.83.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9e703effe3ae779925c82ac24fdb82cf4105e1096810151ed9501c5f34546b9c", size = 7984321, upload-time = "2026-08-28T07:08:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/25/8a/14ec05669f9eb295801e26c2ea8c561a1b786b0e3557c2c22131165ab010/grpcio-1.83.1-cp312-cp312-win32.whl", hash = "sha256:a2aea8bd6e0a34f12cbaddb7bb70bec836818789fa5c7ab7572c6b745396a2d4", size = 4395604, upload-time = "2026-08-28T07:08:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/e9/37/8c2f7cc16089e36a3fbacaacc7a3d043912aa0d2dfae5556f6450414ea6e/grpcio-1.83.1-cp312-cp312-win_amd64.whl", hash = "sha256:583bf2e8255040a4a312f9572dfe62a05271437b149550e1a536d5c47d2d1e8a", size = 5161512, upload-time = "2026-08-28T07:08:25.81Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fd/d1fc58933bf88c9209f89dc570c810f1aa57cb04b3459cf2b26f61e32112/grpcio-1.83.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:8d228e253b77865efcbdd7b5894ca882c9e0ea98c02b7d20582e61ded8dfd4b5", size = 6305628, upload-time = "2026-08-28T07:08:27.872Z" }, + { url = "https://files.pythonhosted.org/packages/c4/49/0b40bae059c619505c9b751cee6caa208e4904e290aaefa1728c4c2c67a5/grpcio-1.83.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:0468b627f2987c9a77f7580030207cbd85457ffe52998beff4f0b5c38c58a72c", size = 12156839, upload-time = "2026-08-28T07:08:30.191Z" }, + { url = "https://files.pythonhosted.org/packages/61/4b/e8c0d635da0ee5ddd9950c8d540f5dcdd0ef1854a382cc55496a487a8d31/grpcio-1.83.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a6a282e81530cead60bbd752cc04950a57f224379e9821495d6a35bd5ce9b1f4", size = 6877036, upload-time = "2026-08-28T07:08:32.285Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d4/760a33f339a7dd3d5f4b3e0e9bec5472d95592a80f887b2e9dab4e41cfbc/grpcio-1.83.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:947d945f52e8ecf3cafd2bb7113502a16ccfda3e12c854443094de32d83ad432", size = 7624404, upload-time = "2026-08-28T07:08:34.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/bd798654b06fb42a92b57d1dc1b530084fa89ed442806fcd0a833a36f9b3/grpcio-1.83.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:55656318d5dd387077396dffb929171ca3966e24bfead9a6c5dba9f889062cb4", size = 7042942, upload-time = "2026-08-28T07:08:36.208Z" }, + { url = "https://files.pythonhosted.org/packages/08/b0/c00f86614566dd0961825cf0f43d4f96a74371d9d95f952bcbc4b86d9a27/grpcio-1.83.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9daf5acf4fc9d5f5627229969c2580a91e511779d76e4ccdeb9f4770f05d8bc2", size = 7576937, upload-time = "2026-08-28T07:08:38.041Z" }, + { url = "https://files.pythonhosted.org/packages/b1/38/85eff43a5c89dc666a252b5c9f8e9ab03f89e11c95b6263d2933f08fdbe7/grpcio-1.83.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7b94174cbca93316888f805efbeb08f1c020f7b7493d2d50cc4f6b64ebb7e8bd", size = 8608391, upload-time = "2026-08-28T07:08:40.092Z" }, + { url = "https://files.pythonhosted.org/packages/35/4e/82835483e2f812494be865e7965c0d626cb9e71ab0d83a420d75aea4ad67/grpcio-1.83.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:65c5a7210911ffe0f67b1cdc5308f9854b6d1f1b345e3e49ab7cac1ba50fa346", size = 7980060, upload-time = "2026-08-28T07:08:42.434Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/68a98bef733fef704fbcfb3957c8dba67e3e38ca7a7fea851195bc97c648/grpcio-1.83.1-cp313-cp313-win32.whl", hash = "sha256:179368d9361854616ce6f397d4716e07480129652752fcbcfc5a7260455ad6f2", size = 4395226, upload-time = "2026-08-28T07:08:44.463Z" }, + { url = "https://files.pythonhosted.org/packages/85/a0/df4de3b51d37ac8fb0320bb9668381ce2bd3b7aa990880bfc56a8a26f665/grpcio-1.83.1-cp313-cp313-win_amd64.whl", hash = "sha256:2e57af456385491a76e13c4aada8c8f43a8e47051e06ea97a9dbe2a49654e6db", size = 5160273, upload-time = "2026-08-28T07:08:46.216Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/484d981d8b90c4e6abf3030bd2ed747e84d1eb192b3ec9cbb41e0b73e4bf/grpcio-1.83.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:8b3c87ca908296bf125f841d3e1a2225a2b39aaa8ed7a57e7ccde465ee519bab", size = 6306089, upload-time = "2026-08-28T07:08:48.379Z" }, + { url = "https://files.pythonhosted.org/packages/84/01/0afec1c92e4f292f74a44ecf75eabbf40903125b8c4df103c9868d6338da/grpcio-1.83.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c0f3f20c90e72a171917ae65706500b096a1c3eb5f162c3ce702a2e25635f132", size = 12170381, upload-time = "2026-08-28T07:08:50.653Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5a/e9a2383804433a0a61d6d93777ad321c7f36ac1cfdaa4c6d1a7c9ac846b7/grpcio-1.83.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81bbf35a46bf8cad2dfbb2eccc19c711befb58b288acb534bbcd0d74283202a6", size = 6883286, upload-time = "2026-08-28T07:08:53.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/f8ca8f76994e14c70b9a0052e82f10de497a23db450c36379c9716ebfc4d/grpcio-1.83.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:215cec07d11176507387bda4bf2751816e880f9bff8dc1ca524bfbb8ed8f2fad", size = 7624293, upload-time = "2026-08-28T07:08:55.709Z" }, + { url = "https://files.pythonhosted.org/packages/bb/7a/4b672814b0cd0fe63bdd735379d88b165759f3144ab023ad8ec5fc4d53ac/grpcio-1.83.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:abce7d43ec29cd39230fa8339de1a07643b55adc412a454850fbd875349950ff", size = 7044346, upload-time = "2026-08-28T07:08:57.802Z" }, + { url = "https://files.pythonhosted.org/packages/50/b8/d89fe60e4239ad51be333dd9cc703741d449a35064e51f8a0b5bfa755432/grpcio-1.83.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e256f95a40e3b0183a98556fb7164d24b97eeb353123ccabfcba94712b35ee2a", size = 7584187, upload-time = "2026-08-28T07:08:59.867Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b2/b290d7402633d9166e4dd47e6f5f74a24ce10a8340b84455896ebc349f85/grpcio-1.83.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2110059146fb0ea216e1ffddb29377b5cc2fd412a5b0a92e102616bd5edf18c2", size = 8608730, upload-time = "2026-08-28T07:09:02.592Z" }, + { url = "https://files.pythonhosted.org/packages/f5/44/fa89e44d1b5cf5b9fa71b2fd7abf506f182fd43917231a92fbf1ea326b02/grpcio-1.83.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20d944d967843f8183f9f23d5916388362e5f8eeeae855bbe4354d906dc9f31b", size = 7983283, upload-time = "2026-08-28T07:09:05.087Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b8/9db73ed1f35ffa76124ac574bf296d06a359798dfd6b50d382f2b8a060a1/grpcio-1.83.1-cp314-cp314-win32.whl", hash = "sha256:623c87c6d4a1cb30d82c4e896f95477050f2e01b4a1f8cf91ff2b1abdf89c457", size = 4474327, upload-time = "2026-08-28T07:09:07.179Z" }, + { url = "https://files.pythonhosted.org/packages/65/22/fc9a622d885a7a37ff972a12faaef443d74e47407181da70d0ab62ab41f0/grpcio-1.83.1-cp314-cp314-win_amd64.whl", hash = "sha256:47e6934ad38779271e2e7cc5f78a63a407cf3d98114c65c1fdbcd3f5a716f29b", size = 5302032, upload-time = "2026-08-28T07:09:09.285Z" }, +] + +[[package]] +name = "grpcio-reflection" +version = "1.83.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/86/7a54dbb97ff190462831079d2d7e348bd417d5ffacbe93541511832b85b6/grpcio_reflection-1.83.1.tar.gz", hash = "sha256:c33953f2eae1313ab1d2c2b6a1cec6ddaebe6763da1b2631980792d9792e6233", size = 19204, upload-time = "2026-08-28T07:12:25.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/f1/397caeda1b4cfdd6873acf6a1603bc6f8774dfbba107d5fd788bcce18e14/grpcio_reflection-1.83.1-py3-none-any.whl", hash = "sha256:6f80bb2a8f8893545b5a1669fad48518a9e4783f8edd739a300be53e7d1ce0b2", size = 22909, upload-time = "2026-08-28T07:12:14.862Z" }, +] + +[[package]] +name = "grpcio-tools" +version = "1.83.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/79/8b4131bcb94f09c2cac4919e627f20c09ba9b22320a307697d72b1f881d4/grpcio_tools-1.83.1.tar.gz", hash = "sha256:a8148eece396f8a349097958bc00f14882003331f3b7c2ae8079c1c632d17d6f", size = 6399877, upload-time = "2026-08-28T07:10:56.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b2/2e04f693d0180d088db1dca1657993fb18f3fd65bd0c0a48f7af7b14dfcd/grpcio_tools-1.83.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:c5c53e9f39eb4a038de869e043798d36509fcb5c4594e0ac44e9b709f1c75b23", size = 2652831, upload-time = "2026-08-28T07:09:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/88/4d/e151cd22813d06e79a79cb0b7bf574cb36eaef64a0125bd8c061f30324b4/grpcio_tools-1.83.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:4509d5ea4f3d6fbfce1652b4ce46dee19c3ec53ca442ba54ec779de05ff2fd06", size = 5967886, upload-time = "2026-08-28T07:09:43.808Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d7/00aca40e28aa3dae45b7c7af29335829e58dffe8931546af2e49dcb7f2f9/grpcio_tools-1.83.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48a9114c992321b6caaa6b04fc9a7e17e86ba007ad078992187d63e0555a1300", size = 2704867, upload-time = "2026-08-28T07:09:45.755Z" }, + { url = "https://files.pythonhosted.org/packages/2e/66/5528b939f3ff1753971833aed31aca2367d1723bd522a4872cb849b85415/grpcio_tools-1.83.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:cce732937363171d916d1411920275aaa831f25cd8aa35c3608b3b526506b25a", size = 3032319, upload-time = "2026-08-28T07:09:47.767Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7d/3b51f79f64419ebd77f753435400bb16cd52ea2361cf778272813fa82608/grpcio_tools-1.83.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6a80ee3cff642502e1c91074c03e8278f344e8343a1fa6f720bec35cbe33737", size = 2774114, upload-time = "2026-08-28T07:09:49.465Z" }, + { url = "https://files.pythonhosted.org/packages/e9/94/302079639ca3d903b383333835ef48afaf8c2f16892242ffcd69648b15c2/grpcio_tools-1.83.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:38eaf68d2a1dcb5a4a046835d70bb8ade872e8807cc7ff3d1c05edd4070c4a29", size = 3226720, upload-time = "2026-08-28T07:09:51.086Z" }, + { url = "https://files.pythonhosted.org/packages/37/41/be5b36ce52086f8b2164a7b241184248b4847b1fa936f61d33773d50b4dd/grpcio_tools-1.83.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ae686c99624b1eed0d17e0281d248fd5e93a43f2d48db4177fd0f5cb9a07fed1", size = 3798998, upload-time = "2026-08-28T07:09:52.724Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8e/39d594f55f3f6f5ebd87f1c0a3e5669c05471c7419ddfa6e7e92228064d4/grpcio_tools-1.83.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:93d80b396126bfb46cc95a1c5f6fc52224497b05f9b3bb5d0800ee6e29306114", size = 3457775, upload-time = "2026-08-28T07:09:54.179Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d5/b222d613671bcc4f4a41c2b980dbcd0e8a6ba33f3727a25422652ce3c4c9/grpcio_tools-1.83.1-cp311-cp311-win32.whl", hash = "sha256:da674e88bef4b627336a75e3b1cc1b3e28fdd78f2607a0b84add66af5796cc68", size = 1022803, upload-time = "2026-08-28T07:09:55.719Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f2/eb6f8c86a5d70e2bfbf8470f309bd95142abcb389aec561f984e1af73ca9/grpcio_tools-1.83.1-cp311-cp311-win_amd64.whl", hash = "sha256:d5e4f8d1c7f6917d3cd99e1a8b1653dc1258c1fa027d103b75160c8b562a1b5a", size = 1192475, upload-time = "2026-08-28T07:09:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/03/91/79261571a7e5f6b5526f2da0fb7bff680af94f5322c8e8923961cbe801ae/grpcio_tools-1.83.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:3a8931c167e52741f72795c67f45435faaefb7ce8e9aa9413ba523ddaa358998", size = 2653283, upload-time = "2026-08-28T07:09:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/03/b2/ea313245d23b0a77edc83b1f01b86304664e79e2221d11cc8cf92985f18b/grpcio_tools-1.83.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:d559e85272145a979e9ad10be2740d953dcd21c37cdad159f06cd7eaf887c898", size = 5965913, upload-time = "2026-08-28T07:10:00.727Z" }, + { url = "https://files.pythonhosted.org/packages/31/ce/125baf82bba77fad398f6139bda5e4383208a5a7f9f1ffc42dd8d973c402/grpcio_tools-1.83.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:169518957cbc23034950006f3eb77514516476496b2951576c531336c61baffb", size = 2705430, upload-time = "2026-08-28T07:10:02.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7c/cc727ab1b4ecc0a6dfe706a714c959d86736153c811e38b5b6723cb7576d/grpcio_tools-1.83.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:d0e9b58917beb6e13d40bb694390dc79c8d7652a1d9ff412e98bdb416d8b135e", size = 3033411, upload-time = "2026-08-28T07:10:04.255Z" }, + { url = "https://files.pythonhosted.org/packages/84/57/e0312f16529cde4de03f532bb8dd4a8ccbf314e7c40cdfdc5586b6c23f85/grpcio_tools-1.83.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:70c74bb6fe0b57b7f4f49caa21d3263e149c2f1e2ef9d53c3d78d622e8564deb", size = 2774499, upload-time = "2026-08-28T07:10:05.928Z" }, + { url = "https://files.pythonhosted.org/packages/b7/83/0ecd3ee6ae20da8454481adf489969f854c56f702fcd2f27b5583fc14eae/grpcio_tools-1.83.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:91a3d0939364e688773c2735286e61107fbacdc104711b0003bd665986b79993", size = 3229873, upload-time = "2026-08-28T07:10:07.576Z" }, + { url = "https://files.pythonhosted.org/packages/09/03/92d4a193578efc5e4a061339dd0dbdcf516eebdb0577646b1bfaf144c803/grpcio_tools-1.83.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:479bb9f4bde497471717e0ee59ed5635b490af954224825bd7006608d1e76d79", size = 3803165, upload-time = "2026-08-28T07:10:09.67Z" }, + { url = "https://files.pythonhosted.org/packages/b2/51/9921cf100111c0e1ba375767cab93be3036b231f22533985ddb497111d4f/grpcio_tools-1.83.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b57f41c87ba0ea0d13d98b696058864d3dea949ecb4a376c3a4a52857330b83f", size = 3461813, upload-time = "2026-08-28T07:10:11.594Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/7fd50b572b38670886e1c41986b785720e24a4b0be8b164fa09e2d915782/grpcio_tools-1.83.1-cp312-cp312-win32.whl", hash = "sha256:eaf9217d67960016fdd2932f28239ec14716a15b783baa8d4b012937098531f1", size = 1022492, upload-time = "2026-08-28T07:10:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/cd/68/45e04d9a921e10158b84b099dbdd25e6d56776d508872c68922a61ff85fd/grpcio_tools-1.83.1-cp312-cp312-win_amd64.whl", hash = "sha256:dcbdd3422ca50ed6f880f9974df50796fb355f4051c55a464423a6423a3f0393", size = 1192288, upload-time = "2026-08-28T07:10:14.777Z" }, + { url = "https://files.pythonhosted.org/packages/d7/34/917a23240209fa5e020d68522b3d11d00da2acc8a784e215aec37b66cc0a/grpcio_tools-1.83.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:7c4ebbf3b13e481134fdf40fa49cb1f570ccb6b1a52c9ccded260428d79add6a", size = 2652846, upload-time = "2026-08-28T07:10:16.778Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b3/e0e2c3cfb5bb1311f11fce1176f47d84374e592ca6abf64d579c9dff701c/grpcio_tools-1.83.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:fe937a0cb78397abdf3ece759beed1fe5763dc530d8165b8b8691cc0770f2523", size = 5963532, upload-time = "2026-08-28T07:10:18.731Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/ce3d17a54ae754bed008303420e3ab88e8c26bd1e97722df188ee22b4583/grpcio_tools-1.83.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:93970064770f45789796c579c9e70fd2371621f5e2c07e64cf0decf7b41d8b6c", size = 2705094, upload-time = "2026-08-28T07:10:20.534Z" }, + { url = "https://files.pythonhosted.org/packages/b2/74/049150098e3dac26053f03d0b3e2f25da354c476302c6a31bb287f1a8493/grpcio_tools-1.83.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2c8c845f2b7d5a229377e3b10ffa281a93fb73e436a0078dc4a58c761ed32434", size = 3033063, upload-time = "2026-08-28T07:10:22.268Z" }, + { url = "https://files.pythonhosted.org/packages/40/fe/e9ec5fb11997318f7dabb1ff7b910b2f76d30c570d0ba56150733f256c25/grpcio_tools-1.83.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e15a089141788fe1268044ccdc43e1467c92d60387ebaf289dfd22a68227d16", size = 2773648, upload-time = "2026-08-28T07:10:23.979Z" }, + { url = "https://files.pythonhosted.org/packages/b4/05/92c1d00dd71fcce02e928f3895571e07b030463ce45e187d162317fcfadf/grpcio_tools-1.83.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9b7a57ce7ee79917d6db94d7ff8356e91c6768b9db62f26f03cd628ec5aacb0b", size = 3229788, upload-time = "2026-08-28T07:10:25.879Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f5/974254c820c5e1f391f7f4cbade03befde72808d8323bbf9d2422614ce17/grpcio_tools-1.83.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fff7380f022deb12e4ca6cf9b26ef0000c1cf9ec8b2af2675a3b5afe42f68828", size = 3802531, upload-time = "2026-08-28T07:10:27.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/c2/816b26ee8f4353ea3207dae5691b08ea9bf53e6a4bacc798742bd3066c66/grpcio_tools-1.83.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0bcb9d5f1f0cd550a45ac811f5bdf5241df1f924ac7c70d186eba7d9a6260e9c", size = 3461030, upload-time = "2026-08-28T07:10:29.611Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/a4c629a5cacaefd468dd8152269dd3a578dd0e8858ed8691878a9c3f545f/grpcio_tools-1.83.1-cp313-cp313-win32.whl", hash = "sha256:08197f91352f0944957372b2c82a4fc64d522b75b5c8c4cdc47186e20e4f7fb6", size = 1022120, upload-time = "2026-08-28T07:10:31.233Z" }, + { url = "https://files.pythonhosted.org/packages/ad/37/b62a44c72df3cc61917f9d7cc42af0d32f60e7ca8b892dfcd95330eae68b/grpcio_tools-1.83.1-cp313-cp313-win_amd64.whl", hash = "sha256:5e9eb45376a8e1d22e2ef067518616738272de223cbb13b2424d7880d04e4dbe", size = 1191938, upload-time = "2026-08-28T07:10:33.108Z" }, + { url = "https://files.pythonhosted.org/packages/34/2b/4e8f3bff52b933c52c853e3a51db8d5e87c71fc0ec3903ef9e432b25e4c5/grpcio_tools-1.83.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:f54955b95b17e83f8075303a31fa8f1aa64ad22d6f046e0070e9973918327202", size = 2652838, upload-time = "2026-08-28T07:10:35.035Z" }, + { url = "https://files.pythonhosted.org/packages/91/01/f5b3648285126a741a1a60108971c25acb0532af8fd2ffb29990747ff8d8/grpcio_tools-1.83.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:0f4c3d39546b0e6add25affd096170347c10437d78189fe2d0a026f7cbc5cb57", size = 5963473, upload-time = "2026-08-28T07:10:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/65/1d/95fc82961423365edc3ac6f98bc12ac96429bfbc5c43ede9ff40354aa273/grpcio_tools-1.83.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:34c8bbfe6e1ed19524010351a27c0d43e185b3d542cf4ab83ad9774d4c9000e1", size = 2705302, upload-time = "2026-08-28T07:10:39.166Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c5/82e29f48fcb5cf77b78c427ccaf5808b1b276cadd42955ceec960ec8daff/grpcio_tools-1.83.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:004bef39f47db3ed93caf58175ad3d3800f53d16a04b493166be945a0b575df3", size = 3033047, upload-time = "2026-08-28T07:10:41.492Z" }, + { url = "https://files.pythonhosted.org/packages/89/38/f17b2902166320912fff7ecb4227a69ddf8edb544702045c3aba3076de0a/grpcio_tools-1.83.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:41a4ce8c2cddb38b50d5befe4f40b124f77d68021e2e47f80142f6138d802d8f", size = 2773829, upload-time = "2026-08-28T07:10:43.367Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/974b54afc6ea3d7d8b51671d0c64effd9a92691a887e9bbf62c8b6213f03/grpcio_tools-1.83.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b52cc836922456d56f25ff63b277410a6d9ad682863f064c1f735a19328432a3", size = 3229905, upload-time = "2026-08-28T07:10:45.758Z" }, + { url = "https://files.pythonhosted.org/packages/8a/87/e9d5ff123ff80d0a7d502e9d2452305812d904e17afb88b3b23d892f3334/grpcio_tools-1.83.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b73afd4303f2b026f8d8f3d7f07f5f22a2f2c3ecb05758af30205db0b16539b9", size = 3802600, upload-time = "2026-08-28T07:10:47.977Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2f/66d8cdc5a81f9723f2b7b2f32d29a757b3bd2eac2116746014626feb83d6/grpcio_tools-1.83.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:df9a2b346b98ec28d5666b43d2c00bb533976faa13f3fab2897890b3703a968a", size = 3461303, upload-time = "2026-08-28T07:10:50.178Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f3/3989a42f1dcc1a1c59f06f5e4fbf48bc50a53a6cc5083c0b6a7aef4c1929/grpcio_tools-1.83.1-cp314-cp314-win32.whl", hash = "sha256:a8cafccbe4b83bcc297920796ae5a784c700c9fbef01705397007bcb4652e070", size = 1045048, upload-time = "2026-08-28T07:10:52.123Z" }, + { url = "https://files.pythonhosted.org/packages/11/bf/be8370e2c3f0108aaae33bd609c359d593648959cc9efe69e6b59ec88a41/grpcio_tools-1.83.1-cp314-cp314-win_amd64.whl", hash = "sha256:9a40b779ec4bbd042279957cd1d0f42d4cbe333878d3d59ceeebe1cf3148528a", size = 1224200, upload-time = "2026-08-28T07:10:54.085Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -537,6 +656,25 @@ requires-dist = [ ] provides-extras = ["tui"] +[[package]] +name = "light-phone-daemon" +version = "0.6.0" +source = { editable = "light_daemon" } +dependencies = [ + { name = "grpcio" }, + { name = "grpcio-reflection" }, + { name = "light-phone-api" }, + { name = "protobuf" }, +] + +[package.metadata] +requires-dist = [ + { name = "grpcio", specifier = ">=1.83.1" }, + { name = "grpcio-reflection", specifier = ">=1.83.1" }, + { name = "light-phone-api", editable = "light_api" }, + { name = "protobuf", specifier = ">=7.35.1" }, +] + [[package]] name = "linkify-it-py" version = "2.1.0" @@ -653,6 +791,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, ] +[[package]] +name = "protobuf" +version = "7.36.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/e7/0553e21d25ca4d9f573135775348a372c3ec34a93a71d5f297c3bac38341/protobuf-7.36.0.tar.gz", hash = "sha256:e8e09cb0d794c6687926fa558a8a6e72aa10edb997d5ca61da0765f12a3e00ea", size = 510034, upload-time = "2026-08-20T16:34:01.071Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/ae/58e3ca96cb2e118cc546b677359b3c6659f79a140935c08dec94c7998585/protobuf-7.36.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:9103532dffd80c6fab7e50c65a31007680a06eb57537d437bb1b35812c138a37", size = 453256, upload-time = "2026-08-20T16:33:53.945Z" }, + { url = "https://files.pythonhosted.org/packages/f0/15/5162230af4912697f0fe406f6800f80760945babcff0e2c2fe6c84ef2d5d/protobuf-7.36.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:bf94a5917c71058262de683669bc0a797a7669d3de71f0b36d058e3194f47b44", size = 341436, upload-time = "2026-08-20T16:33:55.134Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/1670b2bfc9a45e807e520c3e9be36524db9ccc7dc05ea17af7681cabdc61/protobuf-7.36.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:3297e60abdff301e5f74393d87f6cc59dacab5f024a89548a6e8de1d26576b16", size = 354440, upload-time = "2026-08-20T16:33:56.077Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f8/bd5804695ba400e423c33fd4d9f58c28d86633d5ba1945c36ff3967d98cb/protobuf-7.36.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:70f5ec8eb0da81a44360c0dc0beac99a0d78071d21956a7076bae8bd2051841b", size = 340439, upload-time = "2026-08-20T16:33:56.992Z" }, + { url = "https://files.pythonhosted.org/packages/ef/9f/acd02338235a3e7d03168c4303478347b7624fc8189ff4e7f0d2654bbe86/protobuf-7.36.0-cp310-abi3-win32.whl", hash = "sha256:7326fd717bdc419162a735938d89d4032332bcc3408804012b24ff3a37086071", size = 440216, upload-time = "2026-08-20T16:33:57.99Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4e/12cb93270967a2affff5b3f720694700d4d87712a67afd05c8cb3f6fa52c/protobuf-7.36.0-cp310-abi3-win_amd64.whl", hash = "sha256:1781cc1de61249b750848029bca452c0a8b7e990080316b9bbc2518b2117b488", size = 453731, upload-time = "2026-08-20T16:33:58.951Z" }, + { url = "https://files.pythonhosted.org/packages/01/c3/629999e78d46c1115c11886d51c6bd68c17ce4a944f1ea3e153a91316a33/protobuf-7.36.0-py3-none-any.whl", hash = "sha256:53374d53fc29a67f7dbbf0ade47d7526a0f0137bf0f9c90e48d8a60790ef748c", size = 177024, upload-time = "2026-08-20T16:34:00.053Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -999,6 +1152,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + [[package]] name = "six" version = "1.17.0"