kymoscope 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kymoscope-0.1.0/.gitignore +85 -0
- kymoscope-0.1.0/LICENSE.md +69 -0
- kymoscope-0.1.0/PKG-INFO +122 -0
- kymoscope-0.1.0/README.md +36 -0
- kymoscope-0.1.0/pyproject.toml +26 -0
- kymoscope-0.1.0/src/kymoscope/__init__.py +18 -0
- kymoscope-0.1.0/src/kymoscope/broadcast.py +42 -0
- kymoscope-0.1.0/src/kymoscope/client.py +155 -0
- kymoscope-0.1.0/src/kymoscope/config.py +66 -0
- kymoscope-0.1.0/src/kymoscope/crypto.py +153 -0
- kymoscope-0.1.0/src/kymoscope/keystore.py +98 -0
- kymoscope-0.1.0/src/kymoscope/stream.py +606 -0
- kymoscope-0.1.0/src/kymoscope/viewer.py +36 -0
- kymoscope-0.1.0/src/kymoscope/wire.py +190 -0
- kymoscope-0.1.0/tests/__init__.py +0 -0
- kymoscope-0.1.0/tests/test_broadcast.py +42 -0
- kymoscope-0.1.0/tests/test_client.py +212 -0
- kymoscope-0.1.0/tests/test_config.py +41 -0
- kymoscope-0.1.0/tests/test_crypto.py +75 -0
- kymoscope-0.1.0/tests/test_project_keys.py +129 -0
- kymoscope-0.1.0/tests/test_relay_transport.py +199 -0
- kymoscope-0.1.0/tests/test_stream.py +343 -0
- kymoscope-0.1.0/tests/test_stream_e2ee.py +217 -0
- kymoscope-0.1.0/tests/test_vectors_in_sync.py +12 -0
- kymoscope-0.1.0/tests/test_viewer.py +24 -0
- kymoscope-0.1.0/tests/test_wire.py +90 -0
- kymoscope-0.1.0/tests/vectors/project-keys-v1.json +51 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Logs
|
|
2
|
+
logs
|
|
3
|
+
*.log
|
|
4
|
+
npm-debug.log*
|
|
5
|
+
yarn-debug.log*
|
|
6
|
+
yarn-error.log*
|
|
7
|
+
pnpm-debug.log*
|
|
8
|
+
lerna-debug.log*
|
|
9
|
+
|
|
10
|
+
node_modules
|
|
11
|
+
dist
|
|
12
|
+
dist-ssr
|
|
13
|
+
*.local
|
|
14
|
+
|
|
15
|
+
# Editor directories and files
|
|
16
|
+
.vscode/*
|
|
17
|
+
!.vscode/extensions.json
|
|
18
|
+
.idea
|
|
19
|
+
.DS_Store
|
|
20
|
+
*.suo
|
|
21
|
+
*.ntvs*
|
|
22
|
+
*.njsproj
|
|
23
|
+
*.sln
|
|
24
|
+
*.sw?
|
|
25
|
+
|
|
26
|
+
# Local env overrides — never commit
|
|
27
|
+
.env.local
|
|
28
|
+
.env.*.local
|
|
29
|
+
.env.production
|
|
30
|
+
|
|
31
|
+
# Brainstorming visual-companion scratch (mockups, server state)
|
|
32
|
+
.superpowers/
|
|
33
|
+
|
|
34
|
+
# Downloaded Kymoscope sender config — contains an API key, never commit
|
|
35
|
+
kymoscope-stream.json
|
|
36
|
+
**/kymoscope-stream.json
|
|
37
|
+
# Native plugin config — holds the stream password
|
|
38
|
+
**/kymoscope-plugin.json
|
|
39
|
+
# Pre-rename (Prism) names of the same local files
|
|
40
|
+
**/prism-stream.json
|
|
41
|
+
**/prism-plugin.json
|
|
42
|
+
**/prism-lmu-sender.zip
|
|
43
|
+
**/.prism_live_state.json
|
|
44
|
+
|
|
45
|
+
# Python caches
|
|
46
|
+
__pycache__/
|
|
47
|
+
*.pyc
|
|
48
|
+
.pytest_cache/
|
|
49
|
+
|
|
50
|
+
# Built client sender bundle (artifact of examples/build_sender_bundle.py)
|
|
51
|
+
kymoscope-lmu-sender.zip
|
|
52
|
+
**/kymoscope-lmu-sender.zip
|
|
53
|
+
|
|
54
|
+
# PyInstaller scratch
|
|
55
|
+
examples/build/
|
|
56
|
+
examples/*.spec
|
|
57
|
+
|
|
58
|
+
# Sender resume state (stream id+salt per name; local, non-secret)
|
|
59
|
+
.kymoscope_live_state.json
|
|
60
|
+
**/.kymoscope_live_state.json
|
|
61
|
+
|
|
62
|
+
# Playwright MCP scratch (screenshots / snapshots from agent runs)
|
|
63
|
+
.playwright-mcp/
|
|
64
|
+
|
|
65
|
+
# Large local benchmark dataset — keep out of git (use LFS or external storage)
|
|
66
|
+
public/big-telemetry.csv
|
|
67
|
+
.vercel
|
|
68
|
+
.env.local
|
|
69
|
+
|
|
70
|
+
# Supabase CLI local state (linked project, CLI version cache)
|
|
71
|
+
supabase/.temp/
|
|
72
|
+
|
|
73
|
+
# QA run output (screenshots, sender configs with the run's throwaway keys) and reports
|
|
74
|
+
tests/e2e-prod/out/
|
|
75
|
+
tests/e2e-prod/reports/
|
|
76
|
+
|
|
77
|
+
# Import bench files (generated on demand: tests/fixtures/import/gen_bench.py)
|
|
78
|
+
tests/fixtures/import/bench/
|
|
79
|
+
|
|
80
|
+
# Local state of wrangler dev and the Supabase CLI
|
|
81
|
+
.wrangler/
|
|
82
|
+
supabase/.branches/
|
|
83
|
+
|
|
84
|
+
# Python packaging
|
|
85
|
+
.venv/
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Kymoscope SDK License Agreement
|
|
2
|
+
|
|
3
|
+
Copyright © 2026 Kymoscope. All rights reserved.
|
|
4
|
+
|
|
5
|
+
This is proprietary software. It is licensed, not sold. By installing, copying
|
|
6
|
+
or using the Kymoscope SDK (the "Software") you agree to these terms. If you do not
|
|
7
|
+
agree, do not use the Software.
|
|
8
|
+
|
|
9
|
+
> Template notice for the owner: this agreement was drafted as a starting point.
|
|
10
|
+
> Have it reviewed by a lawyer for your jurisdiction and fill in the legal
|
|
11
|
+
> entity, governing law and contact details before relying on it.
|
|
12
|
+
|
|
13
|
+
## 1. Licenses
|
|
14
|
+
|
|
15
|
+
**1.1 Evaluation.** You may use the Software without a license key to evaluate
|
|
16
|
+
it and to develop your integration. Unlicensed use displays a "Powered by
|
|
17
|
+
Kymoscope" attribution in the workspace.
|
|
18
|
+
|
|
19
|
+
**1.2 Production.** Using the Software in a product, service or internal tool
|
|
20
|
+
made available to anyone other than your own developers ("Production Use")
|
|
21
|
+
requires a valid license key issued by Kymoscope for each domain the Software is
|
|
22
|
+
served from, under an order or agreement with Kymoscope.
|
|
23
|
+
|
|
24
|
+
**1.3 Scope.** A license key is personal to the licensee named in it, for the
|
|
25
|
+
domains listed in it and until its expiry date.
|
|
26
|
+
|
|
27
|
+
## 2. Restrictions
|
|
28
|
+
|
|
29
|
+
You must not, and must not allow others to:
|
|
30
|
+
|
|
31
|
+
1. make the Software available in Production Use without a valid license key
|
|
32
|
+
for that domain;
|
|
33
|
+
2. remove, hide, obscure or alter the attribution shown by unlicensed use, or
|
|
34
|
+
the copyright notices in the Software;
|
|
35
|
+
3. circumvent, disable or modify the license check, or create, alter or share
|
|
36
|
+
license keys;
|
|
37
|
+
4. copy, sell, sublicense, distribute or publish the Software, or offer it as a
|
|
38
|
+
standalone component library or SDK, except as embedded in your own product
|
|
39
|
+
as permitted above;
|
|
40
|
+
5. reverse engineer, decompile or disassemble the Software, except to the
|
|
41
|
+
extent applicable law expressly permits it despite this restriction;
|
|
42
|
+
6. use the Software to build a competing embeddable analytics or charting
|
|
43
|
+
product.
|
|
44
|
+
|
|
45
|
+
## 3. Kymoscope services
|
|
46
|
+
|
|
47
|
+
Live-stream relaying, watch tickets and other hosted services are provided
|
|
48
|
+
under your Kymoscope account terms and may be rate limited or suspended for abuse.
|
|
49
|
+
|
|
50
|
+
## 4. Ownership
|
|
51
|
+
|
|
52
|
+
Kymoscope and its licensors own all rights in the Software. No rights are granted
|
|
53
|
+
except those expressly stated here.
|
|
54
|
+
|
|
55
|
+
## 5. Termination
|
|
56
|
+
|
|
57
|
+
This license ends automatically if you breach it. On termination you must stop
|
|
58
|
+
using the Software and delete all copies.
|
|
59
|
+
|
|
60
|
+
## 6. No warranty and limitation of liability
|
|
61
|
+
|
|
62
|
+
The Software is provided "as is", without warranties of any kind. To the
|
|
63
|
+
maximum extent permitted by law, Kymoscope is not liable for any indirect,
|
|
64
|
+
incidental or consequential damages, and its total liability is limited to the
|
|
65
|
+
fees you paid for the Software in the twelve months before the claim.
|
|
66
|
+
|
|
67
|
+
## 7. Contact
|
|
68
|
+
|
|
69
|
+
For license keys and commercial terms, contact Kymoscope.
|
kymoscope-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: kymoscope
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Stream live, end-to-end encrypted telemetry to Kymoscope
|
|
5
|
+
Project-URL: Homepage, https://kymoscope.com
|
|
6
|
+
Project-URL: Documentation, https://kymoscope.com/docs/live
|
|
7
|
+
License: # Kymoscope SDK License Agreement
|
|
8
|
+
|
|
9
|
+
Copyright © 2026 Kymoscope. All rights reserved.
|
|
10
|
+
|
|
11
|
+
This is proprietary software. It is licensed, not sold. By installing, copying
|
|
12
|
+
or using the Kymoscope SDK (the "Software") you agree to these terms. If you do not
|
|
13
|
+
agree, do not use the Software.
|
|
14
|
+
|
|
15
|
+
> Template notice for the owner: this agreement was drafted as a starting point.
|
|
16
|
+
> Have it reviewed by a lawyer for your jurisdiction and fill in the legal
|
|
17
|
+
> entity, governing law and contact details before relying on it.
|
|
18
|
+
|
|
19
|
+
## 1. Licenses
|
|
20
|
+
|
|
21
|
+
**1.1 Evaluation.** You may use the Software without a license key to evaluate
|
|
22
|
+
it and to develop your integration. Unlicensed use displays a "Powered by
|
|
23
|
+
Kymoscope" attribution in the workspace.
|
|
24
|
+
|
|
25
|
+
**1.2 Production.** Using the Software in a product, service or internal tool
|
|
26
|
+
made available to anyone other than your own developers ("Production Use")
|
|
27
|
+
requires a valid license key issued by Kymoscope for each domain the Software is
|
|
28
|
+
served from, under an order or agreement with Kymoscope.
|
|
29
|
+
|
|
30
|
+
**1.3 Scope.** A license key is personal to the licensee named in it, for the
|
|
31
|
+
domains listed in it and until its expiry date.
|
|
32
|
+
|
|
33
|
+
## 2. Restrictions
|
|
34
|
+
|
|
35
|
+
You must not, and must not allow others to:
|
|
36
|
+
|
|
37
|
+
1. make the Software available in Production Use without a valid license key
|
|
38
|
+
for that domain;
|
|
39
|
+
2. remove, hide, obscure or alter the attribution shown by unlicensed use, or
|
|
40
|
+
the copyright notices in the Software;
|
|
41
|
+
3. circumvent, disable or modify the license check, or create, alter or share
|
|
42
|
+
license keys;
|
|
43
|
+
4. copy, sell, sublicense, distribute or publish the Software, or offer it as a
|
|
44
|
+
standalone component library or SDK, except as embedded in your own product
|
|
45
|
+
as permitted above;
|
|
46
|
+
5. reverse engineer, decompile or disassemble the Software, except to the
|
|
47
|
+
extent applicable law expressly permits it despite this restriction;
|
|
48
|
+
6. use the Software to build a competing embeddable analytics or charting
|
|
49
|
+
product.
|
|
50
|
+
|
|
51
|
+
## 3. Kymoscope services
|
|
52
|
+
|
|
53
|
+
Live-stream relaying, watch tickets and other hosted services are provided
|
|
54
|
+
under your Kymoscope account terms and may be rate limited or suspended for abuse.
|
|
55
|
+
|
|
56
|
+
## 4. Ownership
|
|
57
|
+
|
|
58
|
+
Kymoscope and its licensors own all rights in the Software. No rights are granted
|
|
59
|
+
except those expressly stated here.
|
|
60
|
+
|
|
61
|
+
## 5. Termination
|
|
62
|
+
|
|
63
|
+
This license ends automatically if you breach it. On termination you must stop
|
|
64
|
+
using the Software and delete all copies.
|
|
65
|
+
|
|
66
|
+
## 6. No warranty and limitation of liability
|
|
67
|
+
|
|
68
|
+
The Software is provided "as is", without warranties of any kind. To the
|
|
69
|
+
maximum extent permitted by law, Kymoscope is not liable for any indirect,
|
|
70
|
+
incidental or consequential damages, and its total liability is limited to the
|
|
71
|
+
fees you paid for the Software in the twelve months before the claim.
|
|
72
|
+
|
|
73
|
+
## 7. Contact
|
|
74
|
+
|
|
75
|
+
For license keys and commercial terms, contact Kymoscope.
|
|
76
|
+
License-File: LICENSE.md
|
|
77
|
+
Requires-Python: >=3.9
|
|
78
|
+
Requires-Dist: cryptography>=41
|
|
79
|
+
Requires-Dist: requests>=2.28
|
|
80
|
+
Requires-Dist: websocket-client>=1.6
|
|
81
|
+
Provides-Extra: test
|
|
82
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
83
|
+
Provides-Extra: viewer
|
|
84
|
+
Requires-Dist: msgpack>=1.0; extra == 'viewer'
|
|
85
|
+
Description-Content-Type: text/markdown
|
|
86
|
+
|
|
87
|
+
# kymoscope
|
|
88
|
+
|
|
89
|
+
Stream live, end-to-end encrypted telemetry to [Kymoscope](https://kymoscope.com) from Python.
|
|
90
|
+
|
|
91
|
+
Requires Python 3.9 or later.
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pip install kymoscope
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Example
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
import math, time
|
|
101
|
+
from kymoscope import LiveStream
|
|
102
|
+
|
|
103
|
+
API_KEY = "kymoscope_..." # Account -> API & real-time streaming, or a project's API key
|
|
104
|
+
start = time.time()
|
|
105
|
+
|
|
106
|
+
# state_path keeps this sender's identity, so a restart needs no new approval
|
|
107
|
+
with LiveStream(API_KEY, "Rig A", state_path="kymoscope-state.json") as stream:
|
|
108
|
+
print("stream id:", stream.stream_id)
|
|
109
|
+
while True:
|
|
110
|
+
t = time.time() - start
|
|
111
|
+
stream.send([{"t": t, "signals": [
|
|
112
|
+
{"name": "speed", "unit": "m/s", "value": 50 + 10 * math.sin(t)},
|
|
113
|
+
]}])
|
|
114
|
+
time.sleep(1 / 60)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
In an end-to-end encrypted project, the first run waits until a project member's browser
|
|
118
|
+
approves the sender; after that it publishes on its own.
|
|
119
|
+
|
|
120
|
+
The headless viewer (`kymoscope.viewer`) needs msgpack: `pip install 'kymoscope[viewer]'`.
|
|
121
|
+
|
|
122
|
+
Full documentation: https://kymoscope.com/docs/live
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# kymoscope
|
|
2
|
+
|
|
3
|
+
Stream live, end-to-end encrypted telemetry to [Kymoscope](https://kymoscope.com) from Python.
|
|
4
|
+
|
|
5
|
+
Requires Python 3.9 or later.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install kymoscope
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Example
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import math, time
|
|
15
|
+
from kymoscope import LiveStream
|
|
16
|
+
|
|
17
|
+
API_KEY = "kymoscope_..." # Account -> API & real-time streaming, or a project's API key
|
|
18
|
+
start = time.time()
|
|
19
|
+
|
|
20
|
+
# state_path keeps this sender's identity, so a restart needs no new approval
|
|
21
|
+
with LiveStream(API_KEY, "Rig A", state_path="kymoscope-state.json") as stream:
|
|
22
|
+
print("stream id:", stream.stream_id)
|
|
23
|
+
while True:
|
|
24
|
+
t = time.time() - start
|
|
25
|
+
stream.send([{"t": t, "signals": [
|
|
26
|
+
{"name": "speed", "unit": "m/s", "value": 50 + 10 * math.sin(t)},
|
|
27
|
+
]}])
|
|
28
|
+
time.sleep(1 / 60)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
In an end-to-end encrypted project, the first run waits until a project member's browser
|
|
32
|
+
approves the sender; after that it publishes on its own.
|
|
33
|
+
|
|
34
|
+
The headless viewer (`kymoscope.viewer`) needs msgpack: `pip install 'kymoscope[viewer]'`.
|
|
35
|
+
|
|
36
|
+
Full documentation: https://kymoscope.com/docs/live
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kymoscope"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Stream live, end-to-end encrypted telemetry to Kymoscope"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE.md" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = ["requests>=2.28", "cryptography>=41", "websocket-client>=1.6"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
viewer = ["msgpack>=1.0"]
|
|
16
|
+
test = ["pytest>=7"]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://kymoscope.com"
|
|
20
|
+
Documentation = "https://kymoscope.com/docs/live"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/kymoscope"]
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.sdist]
|
|
26
|
+
include = ["src/kymoscope", "tests", "README.md", "LICENSE.md"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Shared Kymoscope Live Streams sender client.
|
|
2
|
+
|
|
3
|
+
A small client for streaming end-to-end-encrypted signals into Kymoscope: load a
|
|
4
|
+
downloaded config, derive an AES key from a password, register a stream, and
|
|
5
|
+
publish frames over a WebSocket to the Kymoscope live relay.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .config import KymoscopeConfig, ConfigError, load_config
|
|
9
|
+
from .crypto import derive_key, generate_salt, make_key_check, KEY_CHECK_PLAINTEXT
|
|
10
|
+
from .client import KymoscopeClient, RegistrationError
|
|
11
|
+
from .stream import LiveStream, ANON_KEY, SUPABASE_URL
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"KymoscopeConfig", "ConfigError", "load_config",
|
|
15
|
+
"derive_key", "generate_salt", "make_key_check", "KEY_CHECK_PLAINTEXT",
|
|
16
|
+
"KymoscopeClient", "RegistrationError",
|
|
17
|
+
"LiveStream", "ANON_KEY", "SUPABASE_URL",
|
|
18
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Broadcast cadence: decide when a sender should flush its pending frame batch.
|
|
2
|
+
|
|
3
|
+
Senders sample at full resolution but should not emit one Realtime message per
|
|
4
|
+
sample — that is what blows past the plan's message quota. Instead they hold
|
|
5
|
+
frames in a `pending` batch and flush it on a *broadcast* cadence: every
|
|
6
|
+
`period` seconds, or as soon as the batch reaches `max_frames` (to bound the
|
|
7
|
+
message payload). This keeps all samples (nothing is dropped) while cutting the
|
|
8
|
+
message count by roughly `sample_rate / broadcast_rate`.
|
|
9
|
+
|
|
10
|
+
`should_flush` is a pure predicate so the loop timing stays trivially testable.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
# Frames handed to one LiveStream.send, which splits them into messages by
|
|
14
|
+
# bytes (the relay's limit): a cap only on how much a backlog holds before a
|
|
15
|
+
# flush. Mirrors the C++ plugin (constants.h MAX_FRAMES_PER_SEND).
|
|
16
|
+
MAX_FRAMES_PER_MESSAGE = 256
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def should_flush(pending_count, now, last_flush, period, max_frames=MAX_FRAMES_PER_MESSAGE):
|
|
20
|
+
"""Whether to flush the pending batch now.
|
|
21
|
+
|
|
22
|
+
Flush when there is at least one pending frame AND either the batch has
|
|
23
|
+
reached `max_frames` (bound payload size, ignoring the timer) or at least
|
|
24
|
+
`period` seconds have elapsed since the last flush. An empty batch never
|
|
25
|
+
flushes.
|
|
26
|
+
"""
|
|
27
|
+
if pending_count <= 0:
|
|
28
|
+
return False
|
|
29
|
+
if pending_count >= max_frames:
|
|
30
|
+
return True
|
|
31
|
+
return (now - last_flush) >= period
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def effective_broadcast_rate(configured, cap):
|
|
35
|
+
"""Clamp a configured broadcast rate to the plan cap.
|
|
36
|
+
|
|
37
|
+
A falsy/zero/None cap means "no cap" (e.g. an older stream-token response
|
|
38
|
+
without maxRateHz), so the configured rate wins.
|
|
39
|
+
"""
|
|
40
|
+
if cap and cap > 0 and cap < configured:
|
|
41
|
+
return cap
|
|
42
|
+
return configured
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Talk to Kymoscope's backend as a stream sender.
|
|
2
|
+
|
|
3
|
+
1. mint_token: API key -> short-lived `pt.` publisher token (stream-token).
|
|
4
|
+
It is NOT a Supabase JWT: it only works against the stream-publisher
|
|
5
|
+
function, so a leaked token/API key cannot read or write account data.
|
|
6
|
+
2. register / heartbeat / delete / resolve_org: the `stream-publisher` edge
|
|
7
|
+
function, authenticated with that token. register and heartbeat return a
|
|
8
|
+
`pp.` publish ticket bound to the stream: the only credential the relay
|
|
9
|
+
accepts on the publish socket.
|
|
10
|
+
3. watch_ticket: API key -> `wt.` watch ticket + join material for one stream
|
|
11
|
+
(stream-watch-ticket), for headless viewers/checkers.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import uuid
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RegistrationError(Exception):
|
|
18
|
+
"""A registry call (register/heartbeat/delete/resolve_org) failed; `status` is the HTTP status when there is one."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message, status=None):
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.status = status
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class KymoscopeClient:
|
|
26
|
+
def __init__(self, base: str, anon_key: str, http):
|
|
27
|
+
self.base = base.rstrip("/")
|
|
28
|
+
self.anon_key = anon_key # kept for callers; the publisher path needs no anon key
|
|
29
|
+
self.http = http # a requests.Session-like object
|
|
30
|
+
# The account's live limits from the last stream-token (see mint_token).
|
|
31
|
+
self.limits = {}
|
|
32
|
+
|
|
33
|
+
def mint_token(self, api_key: str):
|
|
34
|
+
r = self.http.post(
|
|
35
|
+
f"{self.base}/functions/v1/stream-token",
|
|
36
|
+
headers={"x-api-key": api_key},
|
|
37
|
+
timeout=10,
|
|
38
|
+
)
|
|
39
|
+
if r.status_code != 200:
|
|
40
|
+
raise RegistrationError(f"stream-token failed: {r.status_code} {r.text}")
|
|
41
|
+
body = r.json()
|
|
42
|
+
# maxBytesPerSec / maxViewers: what the account negotiated (the relay enforces
|
|
43
|
+
# them); maxMessageBytes: the relay's message size limit. Absent from older servers.
|
|
44
|
+
self.limits = {k: body[k] for k in ("maxBytesPerSec", "maxViewers", "maxMessageBytes") if body.get(k)}
|
|
45
|
+
# The key's id and, for a project key, its project: {id, e2ee} (e2ee: encrypted project).
|
|
46
|
+
self.publisher = {"api_key_id": body.get("apiKeyId"), "project": body.get("project")}
|
|
47
|
+
# maxRateHz caps the sender's broadcast cadence (0/absent = no cap);
|
|
48
|
+
# relayUrl is the live relay the sender publishes to.
|
|
49
|
+
return body["token"], body["userId"], body.get("maxRateHz", 0) or 0, body.get("relayUrl")
|
|
50
|
+
|
|
51
|
+
def _publisher(self, token, body, what):
|
|
52
|
+
r = self.http.post(
|
|
53
|
+
f"{self.base}/functions/v1/stream-publisher",
|
|
54
|
+
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
|
55
|
+
json=body,
|
|
56
|
+
timeout=10,
|
|
57
|
+
)
|
|
58
|
+
if r.status_code != 200:
|
|
59
|
+
# Refusals carry {error, message}: show the server's sentence, not raw JSON.
|
|
60
|
+
try:
|
|
61
|
+
err = r.json()
|
|
62
|
+
detail = f"{err['message']} ({err.get('error')})"
|
|
63
|
+
except (ValueError, KeyError, TypeError):
|
|
64
|
+
detail = r.text
|
|
65
|
+
raise RegistrationError(f"{what} failed: {r.status_code} {detail}", status=r.status_code)
|
|
66
|
+
return r.json()
|
|
67
|
+
|
|
68
|
+
def register_stream(self, token, name, salt, key_check,
|
|
69
|
+
visibility="private", org_id=None, stream_id=None, project=None,
|
|
70
|
+
sender_device=None) -> dict:
|
|
71
|
+
"""Register (or, with a persisted stream_id, re-register) the stream.
|
|
72
|
+
|
|
73
|
+
`project` (name or id) publishes into that project of the user's organization;
|
|
74
|
+
a project API key publishes into its project without it.
|
|
75
|
+
|
|
76
|
+
`sender_device` ({public_key, binding}): this sender as a device of an encrypted project.
|
|
77
|
+
|
|
78
|
+
Returns the row ({id, share_code, ...}) plus "ticket": the `pp.` ticket
|
|
79
|
+
for the relay's publish socket, and for project streams "project"
|
|
80
|
+
({id, e2ee, current_epoch, key}) and "sender_device_id".
|
|
81
|
+
"""
|
|
82
|
+
body = {"action": "register", "name": name, "salt": salt,
|
|
83
|
+
"key_check": key_check, "visibility": visibility}
|
|
84
|
+
if org_id is not None:
|
|
85
|
+
body["org_id"] = org_id
|
|
86
|
+
if project is not None:
|
|
87
|
+
body["project"] = project
|
|
88
|
+
if stream_id is not None:
|
|
89
|
+
# Reuse a known id so a restarted sender keeps the SAME channel
|
|
90
|
+
# (viewers resume seamlessly). The server only allows it for our own id.
|
|
91
|
+
body["stream_id"] = stream_id
|
|
92
|
+
if sender_device is not None:
|
|
93
|
+
body["sender_device"] = sender_device
|
|
94
|
+
res = self._publisher(token, body, "register")
|
|
95
|
+
row = {**res["stream"], "ticket": res["ticket"], "devices": res.get("devices", [])}
|
|
96
|
+
row.update({k: res[k] for k in ("project", "sender_device_id") if k in res})
|
|
97
|
+
return row
|
|
98
|
+
|
|
99
|
+
def resolve_org(self, token, org) -> str:
|
|
100
|
+
"""Resolve an --org value (UUID or name) to an organization id."""
|
|
101
|
+
try:
|
|
102
|
+
return str(uuid.UUID(str(org)))
|
|
103
|
+
except (ValueError, AttributeError, TypeError):
|
|
104
|
+
pass
|
|
105
|
+
try:
|
|
106
|
+
return self._publisher(token, {"action": "resolve_org", "org": str(org)}, "org lookup")["orgId"]
|
|
107
|
+
except RegistrationError as e:
|
|
108
|
+
if " 404 " in str(e):
|
|
109
|
+
raise RegistrationError(
|
|
110
|
+
f"organization '{org}' not found among your organizations") from e
|
|
111
|
+
raise
|
|
112
|
+
|
|
113
|
+
def heartbeat(self, token, stream_id) -> str:
|
|
114
|
+
"""Mark the stream alive; returns a fresh `pp.` publish ticket."""
|
|
115
|
+
return self.heartbeat_with_devices(token, stream_id)[0]
|
|
116
|
+
|
|
117
|
+
def heartbeat_with_devices(self, token, stream_id):
|
|
118
|
+
"""(fresh `pp.` ticket, the owner's devices still lacking a key wrap)."""
|
|
119
|
+
res = self._publisher(token, {"action": "heartbeat", "stream_id": stream_id}, "heartbeat")
|
|
120
|
+
return res["ticket"], res.get("devices", [])
|
|
121
|
+
|
|
122
|
+
def heartbeat_project(self, token, stream_id, sender_device_id, epoch=0) -> dict:
|
|
123
|
+
"""Heartbeat of an encrypted project's stream: {ticket, devices, project}. `epoch`: the
|
|
124
|
+
epoch the stream key is sealed under (0: none yet); one left behind by a rotation is refused."""
|
|
125
|
+
return self._publisher(token, {"action": "heartbeat", "stream_id": stream_id,
|
|
126
|
+
"sender_device_id": sender_device_id, "epoch": epoch}, "heartbeat")
|
|
127
|
+
|
|
128
|
+
def wrap_project_key(self, token, stream_id, epoch, sealed, sender_device_id) -> None:
|
|
129
|
+
"""Upload the stream key sealed under the project key of `epoch` ({iv, ct}), from this sender's device."""
|
|
130
|
+
self._publisher(token, {"action": "wrap_keys", "stream_id": stream_id, "sender_device_id": sender_device_id,
|
|
131
|
+
"project_wrap": {"epoch": epoch, "iv": sealed["iv"], "ct": sealed["ct"]}}, "wrap_keys")
|
|
132
|
+
|
|
133
|
+
def wrap_keys(self, token, stream_id, keys) -> None:
|
|
134
|
+
"""Upload stream-key wraps: [{device_id, wrapped:{epk, iv, ct}}]."""
|
|
135
|
+
self._publisher(token, {"action": "wrap_keys", "stream_id": stream_id, "keys": keys}, "wrap_keys")
|
|
136
|
+
|
|
137
|
+
def delete_stream(self, token, stream_id) -> None:
|
|
138
|
+
# Best-effort; ignore failures. Deleting forgets the stream's project placement.
|
|
139
|
+
try:
|
|
140
|
+
self._publisher(token, {"action": "delete", "stream_id": stream_id}, "delete")
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
def watch_ticket(self, api_key, stream_id):
|
|
145
|
+
"""(`wt.` ticket, {id, name, salt, key_check}) for a stream this key's owner may watch."""
|
|
146
|
+
r = self.http.post(
|
|
147
|
+
f"{self.base}/functions/v1/stream-watch-ticket",
|
|
148
|
+
headers={"x-api-key": api_key, "Content-Type": "application/json"},
|
|
149
|
+
json={"streamId": stream_id},
|
|
150
|
+
timeout=10,
|
|
151
|
+
)
|
|
152
|
+
if r.status_code != 200:
|
|
153
|
+
raise RegistrationError(f"stream-watch-ticket failed: {r.status_code} {r.text}")
|
|
154
|
+
body = r.json()
|
|
155
|
+
return body["ticket"], body["stream"]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Load the Kymoscope sender config (API key, optional base URL) from kymoscope-stream.json.
|
|
2
|
+
|
|
3
|
+
Scripts can simply pass the API key to LiveStream; this file is for senders
|
|
4
|
+
that read it from disk: {"apiKey": "kymoscope_..."} ("base" is optional).
|
|
5
|
+
|
|
6
|
+
Search order when no explicit path is given:
|
|
7
|
+
1. ./kymoscope-stream.json (current directory)
|
|
8
|
+
2. %APPDATA%/kymoscope/kymoscope-stream.json (Windows)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
DEFAULT_BASE = "https://clahbbkqdskkhymymoge.supabase.co"
|
|
19
|
+
|
|
20
|
+
_API_KEY_RE = re.compile(r"^((?:kymoscope|prism)_[A-Za-z0-9_-]+)(?:\.([A-Za-z0-9_-]+))?$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConfigError(Exception):
|
|
24
|
+
"""The sender config is missing or invalid."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class KymoscopeConfig:
|
|
29
|
+
base: str
|
|
30
|
+
api_key: str # the auth part: the only part ever sent to the server
|
|
31
|
+
secret: str | None = None # end-to-end secret: stream keys derive from it, never sent
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_api_key(cls, api_key: str, base: str = DEFAULT_BASE) -> "KymoscopeConfig":
|
|
35
|
+
"""Config from an API key pasted into a script (`kymoscope_<auth>.<secret>`)."""
|
|
36
|
+
m = _API_KEY_RE.match(api_key.strip())
|
|
37
|
+
if not m:
|
|
38
|
+
raise ConfigError("That does not look like a Kymoscope API key.")
|
|
39
|
+
return cls(base=base, api_key=m.group(1), secret=m.group(2))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _candidate_paths():
|
|
43
|
+
yield os.path.join(os.getcwd(), "kymoscope-stream.json")
|
|
44
|
+
appdata = os.environ.get("APPDATA")
|
|
45
|
+
if appdata:
|
|
46
|
+
yield os.path.join(appdata, "kymoscope", "kymoscope-stream.json")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_config(path: str | None = None) -> KymoscopeConfig:
|
|
50
|
+
"""Return a KymoscopeConfig, raising ConfigError if not found / invalid."""
|
|
51
|
+
paths = [path] if path else list(_candidate_paths())
|
|
52
|
+
for p in paths:
|
|
53
|
+
if p and os.path.isfile(p):
|
|
54
|
+
try:
|
|
55
|
+
data = json.loads(open(p, encoding="utf-8").read())
|
|
56
|
+
except (OSError, ValueError) as e:
|
|
57
|
+
raise ConfigError(f"Could not read config {p}: {e}") from e
|
|
58
|
+
base, api_key = data.get("base") or DEFAULT_BASE, data.get("apiKey")
|
|
59
|
+
if not api_key:
|
|
60
|
+
raise ConfigError(f"Config {p} must contain 'apiKey'.")
|
|
61
|
+
return KymoscopeConfig.from_api_key(api_key, base)
|
|
62
|
+
raise ConfigError(
|
|
63
|
+
"No kymoscope-stream.json found. Create an API key in Kymoscope (Account → "
|
|
64
|
+
"API & streaming), save it as {\"apiKey\": \"kymoscope_...\"} in "
|
|
65
|
+
"kymoscope-stream.json next to the sender, or pass --api-key / --config <path>."
|
|
66
|
+
)
|