beam-lan 0.1.0__py3-none-any.whl
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.
- beam/__init__.py +31 -0
- beam/_cloud.py +187 -0
- beam/_crypto.py +93 -0
- beam/_files.py +125 -0
- beam/_protocol.py +161 -0
- beam/_starter.py +276 -0
- beam/packer.py +105 -0
- beam/py.typed +0 -0
- beam/transfer.py +396 -0
- beam_lan-0.1.0.dist-info/METADATA +252 -0
- beam_lan-0.1.0.dist-info/RECORD +13 -0
- beam_lan-0.1.0.dist-info/WHEEL +4 -0
- beam_lan-0.1.0.dist-info/licenses/LICENSE +21 -0
beam/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""beam - zip a project and send it to another laptop, anywhere.
|
|
2
|
+
|
|
3
|
+
import beam
|
|
4
|
+
|
|
5
|
+
code = beam.send("D:/projects/my_app", exclude=[".mp4", ".log"]) # laptop A
|
|
6
|
+
beam.receive(code) # laptop B
|
|
7
|
+
|
|
8
|
+
beam.send("D:/projects/my_app", lan=True) # direct, same Wi-Fi only
|
|
9
|
+
beam.pack("D:/projects/my_app") # only make my_app.zip
|
|
10
|
+
|
|
11
|
+
The zip carries a start.bat that installs what the project needs and runs it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0" # before the imports: _cloud reads it for its User-Agent
|
|
15
|
+
|
|
16
|
+
from ._files import DEFAULT_EXCLUDES # noqa: E402
|
|
17
|
+
from ._protocol import DEFAULT_PORT, BeamError, find_sender, lan_ip # noqa: E402
|
|
18
|
+
from .packer import pack # noqa: E402
|
|
19
|
+
from .transfer import receive, send # noqa: E402
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"send",
|
|
23
|
+
"receive",
|
|
24
|
+
"pack",
|
|
25
|
+
"find_sender",
|
|
26
|
+
"lan_ip",
|
|
27
|
+
"BeamError",
|
|
28
|
+
"DEFAULT_EXCLUDES",
|
|
29
|
+
"DEFAULT_PORT",
|
|
30
|
+
"__version__",
|
|
31
|
+
]
|
beam/_cloud.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Free temporary file hosts that carry the encrypted zip between laptops.
|
|
2
|
+
|
|
3
|
+
Each host is tried in order. The code starts with the host's letter, so the
|
|
4
|
+
receiver knows where to download from.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import secrets
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from ._protocol import BeamError
|
|
17
|
+
|
|
18
|
+
USER_AGENT = f"beam-lan/{__version__} (+https://pypi.org/project/beam-lan/)"
|
|
19
|
+
UPLOAD_NAME = "beam.bin"
|
|
20
|
+
BLOCK = 1 << 16
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _Host:
|
|
24
|
+
letter = ""
|
|
25
|
+
label = ""
|
|
26
|
+
max_bytes = 0
|
|
27
|
+
keeps = ""
|
|
28
|
+
|
|
29
|
+
def upload(self, path, progress) -> str:
|
|
30
|
+
"""Upload ``path`` and return the host's id for it."""
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
|
|
33
|
+
def download_request(self, file_id) -> urllib.request.Request:
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TempSh(_Host):
|
|
38
|
+
letter, label, max_bytes, keeps = "t", "temp.sh", 4 * 1024**3, "3 days"
|
|
39
|
+
|
|
40
|
+
def upload(self, path, progress):
|
|
41
|
+
url = _post_file("https://temp.sh/upload", "file", path, progress).strip()
|
|
42
|
+
# https://temp.sh/<id>/beam.bin
|
|
43
|
+
parts = url.rstrip("/").split("/")
|
|
44
|
+
if len(parts) < 2 or not url.startswith("https://temp.sh/"):
|
|
45
|
+
raise BeamError(f"temp.sh gave an unexpected reply: {url[:100]!r}")
|
|
46
|
+
return parts[-2]
|
|
47
|
+
|
|
48
|
+
def download_request(self, file_id):
|
|
49
|
+
# temp.sh serves the file itself only to a POST; a GET returns a page.
|
|
50
|
+
return urllib.request.Request(
|
|
51
|
+
f"https://temp.sh/{file_id}/{UPLOAD_NAME}", data=b"", method="POST"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Uguu(_Host):
|
|
56
|
+
letter, label, max_bytes, keeps = "u", "uguu.se", 128 * 1024**2, "3 hours"
|
|
57
|
+
|
|
58
|
+
def upload(self, path, progress):
|
|
59
|
+
reply = _post_file("https://uguu.se/upload", "files[]", path, progress)
|
|
60
|
+
try:
|
|
61
|
+
url = json.loads(reply)["files"][0]["url"]
|
|
62
|
+
except (ValueError, KeyError, IndexError, TypeError):
|
|
63
|
+
raise BeamError(
|
|
64
|
+
f"uguu.se gave an unexpected reply: {reply[:100]!r}"
|
|
65
|
+
) from None
|
|
66
|
+
# https://<server>.uguu.se/<name>.bin - the server letter varies
|
|
67
|
+
prefix = "https://"
|
|
68
|
+
server, _, name = url[len(prefix) :].partition(".uguu.se/")
|
|
69
|
+
if not url.startswith(prefix) or not name.endswith(".bin") or "/" in name:
|
|
70
|
+
raise BeamError(f"uguu.se gave an unexpected reply: {url[:100]!r}")
|
|
71
|
+
return f"{server}.{name[: -len('.bin')]}"
|
|
72
|
+
|
|
73
|
+
def download_request(self, file_id):
|
|
74
|
+
server, _, name = file_id.partition(".")
|
|
75
|
+
return urllib.request.Request(f"https://{server}.uguu.se/{name}.bin")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
HOSTS = [TempSh(), Uguu()]
|
|
79
|
+
BY_LETTER = {host.letter: host for host in HOSTS}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _MultipartBody:
|
|
83
|
+
"""File-like multipart/form-data body, streamed from disk with progress."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, field, path, progress):
|
|
86
|
+
self.boundary = "beam" + secrets.token_hex(16)
|
|
87
|
+
self.head = (
|
|
88
|
+
f"--{self.boundary}\r\n"
|
|
89
|
+
f'Content-Disposition: form-data; name="{field}"; '
|
|
90
|
+
f'filename="{UPLOAD_NAME}"\r\n'
|
|
91
|
+
"Content-Type: application/octet-stream\r\n\r\n"
|
|
92
|
+
).encode()
|
|
93
|
+
self.tail = f"\r\n--{self.boundary}--\r\n".encode()
|
|
94
|
+
self.size = os.path.getsize(path)
|
|
95
|
+
self.length = len(self.head) + self.size + len(self.tail)
|
|
96
|
+
self.fh = open(path, "rb")
|
|
97
|
+
self.stage = 0
|
|
98
|
+
self.sent = 0
|
|
99
|
+
self.progress = progress
|
|
100
|
+
|
|
101
|
+
def read(self, n=-1):
|
|
102
|
+
if self.stage == 0:
|
|
103
|
+
self.stage = 1
|
|
104
|
+
return self.head
|
|
105
|
+
if self.stage == 1:
|
|
106
|
+
data = self.fh.read(BLOCK if n is None or n < 0 else n)
|
|
107
|
+
if data:
|
|
108
|
+
self.sent += len(data)
|
|
109
|
+
if self.progress:
|
|
110
|
+
self.progress(self.sent, self.size)
|
|
111
|
+
return data
|
|
112
|
+
self.stage = 2
|
|
113
|
+
return self.tail
|
|
114
|
+
return b""
|
|
115
|
+
|
|
116
|
+
def close(self):
|
|
117
|
+
self.fh.close()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _post_file(url, field, path, progress) -> str:
|
|
121
|
+
body = _MultipartBody(field, path, progress)
|
|
122
|
+
request = urllib.request.Request(
|
|
123
|
+
url,
|
|
124
|
+
data=body,
|
|
125
|
+
method="POST",
|
|
126
|
+
headers={
|
|
127
|
+
"Content-Type": f"multipart/form-data; boundary={body.boundary}",
|
|
128
|
+
"Content-Length": str(body.length),
|
|
129
|
+
"User-Agent": USER_AGENT,
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
try:
|
|
133
|
+
with urllib.request.urlopen(request, timeout=120) as resp:
|
|
134
|
+
return resp.read(1 << 16).decode("utf-8", "replace")
|
|
135
|
+
finally:
|
|
136
|
+
body.close()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def upload(path, progress=None, say=None) -> tuple[str, str, str]:
|
|
140
|
+
"""Upload to the first host that takes it. Returns (letter, file_id, keeps)."""
|
|
141
|
+
size = os.path.getsize(path)
|
|
142
|
+
errors = []
|
|
143
|
+
for host in HOSTS:
|
|
144
|
+
if size > host.max_bytes:
|
|
145
|
+
errors.append(f"{host.label}: file is over its size limit")
|
|
146
|
+
continue
|
|
147
|
+
if say:
|
|
148
|
+
say(f" uploading to {host.label} ...")
|
|
149
|
+
try:
|
|
150
|
+
return host.letter, host.upload(path, progress), host.keeps
|
|
151
|
+
except (OSError, BeamError) as exc: # URLError is an OSError
|
|
152
|
+
errors.append(f"{host.label}: {exc}")
|
|
153
|
+
if say:
|
|
154
|
+
say(f"\n {host.label} failed ({exc}), trying the next one")
|
|
155
|
+
raise BeamError("upload failed on every host:\n " + "\n ".join(errors))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def download(letter, file_id, dst, progress=None) -> None:
|
|
159
|
+
host = BY_LETTER.get(letter)
|
|
160
|
+
if host is None:
|
|
161
|
+
raise BeamError(
|
|
162
|
+
"this code is not valid (unknown host); check it was copied fully"
|
|
163
|
+
)
|
|
164
|
+
request = host.download_request(file_id)
|
|
165
|
+
request.add_header("User-Agent", USER_AGENT)
|
|
166
|
+
try:
|
|
167
|
+
with urllib.request.urlopen(request, timeout=120) as resp:
|
|
168
|
+
total = int(resp.headers.get("Content-Length") or 0)
|
|
169
|
+
done = 0
|
|
170
|
+
with open(dst, "wb") as fh:
|
|
171
|
+
for block in iter(lambda: resp.read(BLOCK), b""):
|
|
172
|
+
fh.write(block)
|
|
173
|
+
done += len(block)
|
|
174
|
+
if progress:
|
|
175
|
+
progress(done, total)
|
|
176
|
+
except urllib.error.HTTPError as exc:
|
|
177
|
+
if exc.code in (404, 410):
|
|
178
|
+
raise BeamError(
|
|
179
|
+
f"nothing found for this code on {host.label}: it has expired "
|
|
180
|
+
f"(kept for {host.keeps}) or was mistyped"
|
|
181
|
+
) from None
|
|
182
|
+
raise BeamError(f"download failed: {host.label} said {exc.code}") from None
|
|
183
|
+
except urllib.error.URLError as exc:
|
|
184
|
+
raise BeamError(
|
|
185
|
+
f"could not reach {host.label} ({exc.reason}); "
|
|
186
|
+
"check the internet connection"
|
|
187
|
+
) from None
|
beam/_crypto.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Encrypting a file before it leaves the laptop, and decrypting it on arrival.
|
|
2
|
+
|
|
3
|
+
AES-256-GCM over 1 MiB chunks. Each chunk's nonce carries its index and a
|
|
4
|
+
"last chunk" flag, so reordered, dropped or truncated chunks fail to decrypt.
|
|
5
|
+
The key comes from the secret half of the code, which never reaches the host.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import struct
|
|
15
|
+
|
|
16
|
+
from cryptography.exceptions import InvalidTag
|
|
17
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
18
|
+
|
|
19
|
+
from ._protocol import BeamError
|
|
20
|
+
|
|
21
|
+
MAGIC = b"BEAM\x01"
|
|
22
|
+
CHUNK = 1 << 20
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _key(secret: str, salt: bytes) -> bytes:
|
|
26
|
+
return hmac.new(secret.encode(), b"beam-v1" + salt, hashlib.sha256).digest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _nonce(prefix: bytes, index: int, last: bool) -> bytes:
|
|
30
|
+
return prefix + struct.pack("!IB", index, int(last))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def encrypt_file(src, dst, secret: str, meta: dict, progress=None) -> None:
|
|
34
|
+
"""Write ``src`` to ``dst`` encrypted; ``meta`` (e.g. the filename) rides along."""
|
|
35
|
+
salt, prefix = os.urandom(16), os.urandom(7)
|
|
36
|
+
aes = AESGCM(_key(secret, salt))
|
|
37
|
+
total = os.path.getsize(src)
|
|
38
|
+
done = 0
|
|
39
|
+
with open(src, "rb") as fin, open(dst, "wb") as fout:
|
|
40
|
+
fout.write(MAGIC + salt + prefix)
|
|
41
|
+
blob = aes.encrypt(_nonce(prefix, 0, False), json.dumps(meta).encode(), None)
|
|
42
|
+
fout.write(struct.pack("!BI", 0, len(blob)) + blob)
|
|
43
|
+
index = 1
|
|
44
|
+
block = fin.read(CHUNK)
|
|
45
|
+
while True:
|
|
46
|
+
nxt = fin.read(CHUNK) if len(block) == CHUNK else b""
|
|
47
|
+
last = not nxt
|
|
48
|
+
blob = aes.encrypt(_nonce(prefix, index, last), block, None)
|
|
49
|
+
fout.write(struct.pack("!BI", int(last), len(blob)) + blob)
|
|
50
|
+
done += len(block)
|
|
51
|
+
if progress:
|
|
52
|
+
progress(done, total)
|
|
53
|
+
if last:
|
|
54
|
+
break
|
|
55
|
+
block, index = nxt, index + 1
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def decrypt_file(src, dst, secret: str) -> dict:
|
|
59
|
+
"""Decrypt ``src`` into ``dst``; return the metadata stored with it."""
|
|
60
|
+
wrong = BeamError("could not decrypt: wrong code, or the file was damaged")
|
|
61
|
+
with open(src, "rb") as fin:
|
|
62
|
+
head = fin.read(len(MAGIC) + 16 + 7)
|
|
63
|
+
if not head.startswith(MAGIC) or len(head) < len(MAGIC) + 23:
|
|
64
|
+
raise BeamError("this download is not a beam file (expired or wrong code?)")
|
|
65
|
+
salt, prefix = head[5:21], head[21:28]
|
|
66
|
+
aes = AESGCM(_key(secret, salt))
|
|
67
|
+
|
|
68
|
+
def chunk(index):
|
|
69
|
+
raw = fin.read(5)
|
|
70
|
+
if len(raw) < 5:
|
|
71
|
+
raise BeamError("download is incomplete; try again")
|
|
72
|
+
last, size = struct.unpack("!BI", raw)
|
|
73
|
+
if size > CHUNK + 16:
|
|
74
|
+
raise wrong
|
|
75
|
+
data = fin.read(size)
|
|
76
|
+
try:
|
|
77
|
+
return bool(last), aes.decrypt(
|
|
78
|
+
_nonce(prefix, index, bool(last)), data, None
|
|
79
|
+
)
|
|
80
|
+
except InvalidTag:
|
|
81
|
+
raise wrong from None
|
|
82
|
+
|
|
83
|
+
_, meta_raw = chunk(0)
|
|
84
|
+
meta = json.loads(meta_raw)
|
|
85
|
+
with open(dst, "wb") as fout:
|
|
86
|
+
index, last = 1, False
|
|
87
|
+
while not last:
|
|
88
|
+
last, data = chunk(index)
|
|
89
|
+
fout.write(data)
|
|
90
|
+
index += 1
|
|
91
|
+
if fin.read(1):
|
|
92
|
+
raise wrong
|
|
93
|
+
return meta
|
beam/_files.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Walking the project tree, exclude rules, hashing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
HASH_CHUNK = 1 << 20
|
|
11
|
+
|
|
12
|
+
# Things you almost never want to carry between machines: virtualenvs are
|
|
13
|
+
# platform-specific, caches are regenerable, .git is better cloned, .env
|
|
14
|
+
# usually holds secrets.
|
|
15
|
+
DEFAULT_EXCLUDES = [
|
|
16
|
+
".git",
|
|
17
|
+
".hg",
|
|
18
|
+
".svn",
|
|
19
|
+
"__pycache__",
|
|
20
|
+
"*.pyc",
|
|
21
|
+
"*.pyo",
|
|
22
|
+
".venv",
|
|
23
|
+
"venv",
|
|
24
|
+
"env",
|
|
25
|
+
".env",
|
|
26
|
+
".ipynb_checkpoints",
|
|
27
|
+
".mypy_cache",
|
|
28
|
+
".pytest_cache",
|
|
29
|
+
".ruff_cache",
|
|
30
|
+
".tox",
|
|
31
|
+
"*.egg-info",
|
|
32
|
+
"node_modules",
|
|
33
|
+
".DS_Store",
|
|
34
|
+
"Thumbs.db",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def human(n: float) -> str:
|
|
39
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
40
|
+
if n < 1024 or unit == "GB":
|
|
41
|
+
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
|
|
42
|
+
n /= 1024
|
|
43
|
+
return f"{n:.1f} GB"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def normalize_excludes(exclude) -> list[str]:
|
|
47
|
+
"""Turn user-friendly excludes into glob patterns.
|
|
48
|
+
|
|
49
|
+
".mp4", "mp4" and "*.mp4" all skip mp4 files. A bare name like "data" or
|
|
50
|
+
".cache" also skips a file or folder with exactly that name.
|
|
51
|
+
"""
|
|
52
|
+
if exclude is None:
|
|
53
|
+
return []
|
|
54
|
+
if isinstance(exclude, (str, os.PathLike)):
|
|
55
|
+
exclude = [exclude]
|
|
56
|
+
patterns = []
|
|
57
|
+
for item in exclude:
|
|
58
|
+
item = str(item).strip().replace("\\", "/")
|
|
59
|
+
if not item:
|
|
60
|
+
continue
|
|
61
|
+
if any(ch in item for ch in "*?[/"):
|
|
62
|
+
patterns.append(item)
|
|
63
|
+
else:
|
|
64
|
+
patterns.append(item)
|
|
65
|
+
patterns.append("*." + item.lstrip("."))
|
|
66
|
+
return patterns
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def excluded(rel: Path, patterns) -> bool:
|
|
70
|
+
text = rel.as_posix()
|
|
71
|
+
for pat in patterns:
|
|
72
|
+
pat = pat.rstrip("/")
|
|
73
|
+
if fnmatch.fnmatch(rel.name, pat):
|
|
74
|
+
return True
|
|
75
|
+
if any(fnmatch.fnmatch(part, pat) for part in rel.parts):
|
|
76
|
+
return True
|
|
77
|
+
if fnmatch.fnmatch(text, pat):
|
|
78
|
+
return True
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def read_ignore_file(root: Path) -> list:
|
|
83
|
+
"""Read .beamignore if present. Simple glob patterns, one per line."""
|
|
84
|
+
path = root / ".beamignore"
|
|
85
|
+
if not path.is_file():
|
|
86
|
+
return []
|
|
87
|
+
out = []
|
|
88
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
89
|
+
line = line.strip()
|
|
90
|
+
if line and not line.startswith("#"):
|
|
91
|
+
out.append(line)
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def sha256_file(path: Path) -> str:
|
|
96
|
+
digest = hashlib.sha256()
|
|
97
|
+
with path.open("rb") as fh:
|
|
98
|
+
for block in iter(lambda: fh.read(HASH_CHUNK), b""):
|
|
99
|
+
digest.update(block)
|
|
100
|
+
return digest.hexdigest()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def walk(root: Path, patterns, max_bytes=None):
|
|
104
|
+
"""Return ([(abs_path, rel_path, size)], [(rel_path, size)]) - kept, skipped."""
|
|
105
|
+
found, skipped = [], []
|
|
106
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
107
|
+
here = Path(dirpath)
|
|
108
|
+
rel_dir = here.relative_to(root)
|
|
109
|
+
dirnames[:] = sorted(d for d in dirnames if not excluded(rel_dir / d, patterns))
|
|
110
|
+
for name in sorted(filenames):
|
|
111
|
+
rel = rel_dir / name
|
|
112
|
+
if excluded(rel, patterns):
|
|
113
|
+
continue
|
|
114
|
+
abs_path = here / name
|
|
115
|
+
if abs_path.is_symlink() and not abs_path.exists():
|
|
116
|
+
continue # broken symlink
|
|
117
|
+
try:
|
|
118
|
+
size = abs_path.stat().st_size
|
|
119
|
+
except OSError:
|
|
120
|
+
continue
|
|
121
|
+
if max_bytes is not None and size > max_bytes:
|
|
122
|
+
skipped.append((rel, size))
|
|
123
|
+
continue
|
|
124
|
+
found.append((abs_path, rel, size))
|
|
125
|
+
return found, skipped
|
beam/_protocol.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Wire protocol: framing, errors, and network helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import socket
|
|
8
|
+
import struct
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
PROTOCOL = 2
|
|
13
|
+
DEFAULT_PORT = 8765
|
|
14
|
+
DISCOVERY_PORT = 8766
|
|
15
|
+
MAX_HEADER = 1024 * 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BeamError(RuntimeError):
|
|
19
|
+
"""The other side refused us, or the stream was malformed."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def send_msg(sock: socket.socket, obj: dict) -> None:
|
|
23
|
+
raw = json.dumps(obj).encode("utf-8")
|
|
24
|
+
sock.sendall(struct.pack("!I", len(raw)) + raw)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def recv_exact(sock: socket.socket, n: int) -> bytes:
|
|
28
|
+
buf = bytearray()
|
|
29
|
+
while len(buf) < n:
|
|
30
|
+
chunk = sock.recv(n - len(buf))
|
|
31
|
+
if not chunk:
|
|
32
|
+
raise BeamError("connection closed mid-message")
|
|
33
|
+
buf += chunk
|
|
34
|
+
return bytes(buf)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def recv_msg(sock: socket.socket) -> dict:
|
|
38
|
+
(length,) = struct.unpack("!I", recv_exact(sock, 4))
|
|
39
|
+
if length > MAX_HEADER:
|
|
40
|
+
raise BeamError("refusing an absurdly large header")
|
|
41
|
+
return json.loads(recv_exact(sock, length).decode("utf-8"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def lan_ip() -> str:
|
|
45
|
+
"""Best guess at this machine's address on the local network."""
|
|
46
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
47
|
+
try:
|
|
48
|
+
sock.connect(("8.8.8.8", 80)) # no packets are actually sent
|
|
49
|
+
return sock.getsockname()[0]
|
|
50
|
+
except OSError:
|
|
51
|
+
return "127.0.0.1"
|
|
52
|
+
finally:
|
|
53
|
+
sock.close()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --- discovery ---------------------------------------------------------------
|
|
57
|
+
#
|
|
58
|
+
# The receiver only knows the code. It broadcasts a UDP query carrying a hash
|
|
59
|
+
# of the code; the sender with that code answers with its TCP port, and the
|
|
60
|
+
# reply's source address tells the receiver where to connect.
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def code_tag(code: str) -> str:
|
|
64
|
+
return hashlib.sha256(f"beam:{code}".encode()).hexdigest()[:16]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _local_ipv4s() -> set[str]:
|
|
68
|
+
ips = {lan_ip()}
|
|
69
|
+
try:
|
|
70
|
+
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
|
71
|
+
ips.add(info[4][0])
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
return {ip for ip in ips if not ip.startswith("127.")}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _broadcast_targets() -> list[str]:
|
|
78
|
+
targets = ["255.255.255.255", "127.0.0.1"]
|
|
79
|
+
# Windows often sends 255.255.255.255 out of one adapter only, so also hit
|
|
80
|
+
# each adapter's subnet broadcast (assuming the usual home /24).
|
|
81
|
+
for ip in _local_ipv4s():
|
|
82
|
+
targets.append(ip.rsplit(".", 1)[0] + ".255")
|
|
83
|
+
return targets
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class DiscoveryResponder:
|
|
87
|
+
"""Answers discovery queries for one code until stopped."""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self, code: str, port: int, name: str, udp_port: int = DISCOVERY_PORT
|
|
91
|
+
):
|
|
92
|
+
self.tag = code_tag(code)
|
|
93
|
+
self.reply = json.dumps(
|
|
94
|
+
{"beam": PROTOCOL, "id": self.tag, "port": port, "name": name}
|
|
95
|
+
).encode("utf-8")
|
|
96
|
+
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
97
|
+
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
98
|
+
self.sock.bind(("", udp_port))
|
|
99
|
+
self.sock.settimeout(0.5)
|
|
100
|
+
self._stop = threading.Event()
|
|
101
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
102
|
+
|
|
103
|
+
def start(self) -> DiscoveryResponder:
|
|
104
|
+
self._thread.start()
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
def _run(self) -> None:
|
|
108
|
+
while not self._stop.is_set():
|
|
109
|
+
try:
|
|
110
|
+
data, addr = self.sock.recvfrom(2048)
|
|
111
|
+
query = json.loads(data.decode("utf-8"))
|
|
112
|
+
except (OSError, ValueError): # timeout, ICMP reset, junk
|
|
113
|
+
continue
|
|
114
|
+
if isinstance(query, dict) and query.get("find") == self.tag:
|
|
115
|
+
try:
|
|
116
|
+
self.sock.sendto(self.reply, addr)
|
|
117
|
+
except OSError:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
def stop(self) -> None:
|
|
121
|
+
self._stop.set()
|
|
122
|
+
self._thread.join(timeout=2)
|
|
123
|
+
self.sock.close()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def find_sender(
|
|
127
|
+
code: str, timeout: float = 15.0, udp_port: int = DISCOVERY_PORT
|
|
128
|
+
) -> tuple[str, int, str]:
|
|
129
|
+
"""Locate the sender holding ``code`` on the LAN. Returns (host, port, name)."""
|
|
130
|
+
tag = code_tag(code)
|
|
131
|
+
query = json.dumps({"beam": PROTOCOL, "find": tag}).encode("utf-8")
|
|
132
|
+
targets = _broadcast_targets()
|
|
133
|
+
|
|
134
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
135
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
136
|
+
sock.bind(("", 0))
|
|
137
|
+
sock.settimeout(0.3)
|
|
138
|
+
deadline = time.monotonic() + timeout
|
|
139
|
+
next_ping = 0.0
|
|
140
|
+
try:
|
|
141
|
+
while time.monotonic() < deadline:
|
|
142
|
+
if time.monotonic() >= next_ping:
|
|
143
|
+
for target in targets:
|
|
144
|
+
try:
|
|
145
|
+
sock.sendto(query, (target, udp_port))
|
|
146
|
+
except OSError:
|
|
147
|
+
pass
|
|
148
|
+
next_ping = time.monotonic() + 1.0
|
|
149
|
+
try:
|
|
150
|
+
data, addr = sock.recvfrom(2048)
|
|
151
|
+
reply = json.loads(data.decode("utf-8"))
|
|
152
|
+
except (OSError, ValueError):
|
|
153
|
+
continue
|
|
154
|
+
if isinstance(reply, dict) and reply.get("id") == tag:
|
|
155
|
+
return addr[0], int(reply["port"]), str(reply.get("name", ""))
|
|
156
|
+
finally:
|
|
157
|
+
sock.close()
|
|
158
|
+
raise BeamError(
|
|
159
|
+
f"no sender with code {code!r} found on this network after {timeout:.0f}s "
|
|
160
|
+
"(same Wi-Fi? firewall allowing Python? or pass host=IP)"
|
|
161
|
+
)
|