heathrow 0.2__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.
- heathrow/bencode.py +92 -0
- heathrow/bitset.py +94 -0
- heathrow/constants.py +1 -0
- heathrow/discovery/__init__.py +8 -0
- heathrow/discovery/base.py +42 -0
- heathrow/discovery/destination.py +118 -0
- heathrow/driver/__init__.py +9 -0
- heathrow/driver/base.py +13 -0
- heathrow/driver/basic.py +39 -0
- heathrow/driver/heathrow/__init__.py +238 -0
- heathrow/driver/heathrow/constants.py +5 -0
- heathrow/driver/heathrow/util.py +22 -0
- heathrow/driver/heathrow/windows/__init__.py +14 -0
- heathrow/driver/heathrow/windows/base.py +15 -0
- heathrow/driver/heathrow/windows/dialogue.py +485 -0
- heathrow/driver/heathrow/windows/root.py +211 -0
- heathrow/driver/heathrow/windows/splash.py +53 -0
- heathrow/economy/__init__.py +10 -0
- heathrow/economy/base.py +52 -0
- heathrow/economy/proto.py +153 -0
- heathrow/economy/reference.py +463 -0
- heathrow/fileset/__init__.py +11 -0
- heathrow/fileset/base.py +145 -0
- heathrow/fileset/v1.py +364 -0
- heathrow/main.py +10 -0
- heathrow/messages.py +278 -0
- heathrow/wire/__init__.py +18 -0
- heathrow/wire/base.py +213 -0
- heathrow/wire/buffered.py +200 -0
- heathrow/wire/ltep.py +163 -0
- heathrow/wire/reticulated.py +216 -0
- heathrow/wire/reticulated_v2.py +202 -0
- heathrow-0.2.dist-info/METADATA +89 -0
- heathrow-0.2.dist-info/RECORD +38 -0
- heathrow-0.2.dist-info/WHEEL +5 -0
- heathrow-0.2.dist-info/entry_points.txt +2 -0
- heathrow-0.2.dist-info/licenses/COPYING +674 -0
- heathrow-0.2.dist-info/top_level.txt +1 -0
heathrow/bencode.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Bencodable = dict | str | int | bytes | tuple
|
|
2
|
+
def bencode(data: Bencodable) -> bytes:
|
|
3
|
+
if isinstance(data, bytes):
|
|
4
|
+
return f"{len(data)}:".encode() + data
|
|
5
|
+
if isinstance(data, str):
|
|
6
|
+
return f"{len(data.encode())}:{data}".encode()
|
|
7
|
+
if isinstance(data, int):
|
|
8
|
+
return f"i{str(data)}e".encode()
|
|
9
|
+
if isinstance(data, tuple):
|
|
10
|
+
list_string = b"l"
|
|
11
|
+
for i in data:
|
|
12
|
+
list_string += bencode(i)
|
|
13
|
+
list_string += b"e"
|
|
14
|
+
return list_string
|
|
15
|
+
if isinstance(data, dict):
|
|
16
|
+
keys = sorted(data.keys())
|
|
17
|
+
dict_string = b"d"
|
|
18
|
+
for key in keys:
|
|
19
|
+
dict_string += bencode(key)
|
|
20
|
+
dict_string += bencode(data[key])
|
|
21
|
+
dict_string += b"e"
|
|
22
|
+
return dict_string
|
|
23
|
+
|
|
24
|
+
def _bdecode_string(raw: bytes) -> tuple[bytes, int]:
|
|
25
|
+
strlen = int(raw.split(b":", 1)[0])
|
|
26
|
+
length = strlen + 1 + raw.find(b":")
|
|
27
|
+
result = raw[raw.find(b":")+1:raw.find(b":")+1+strlen]
|
|
28
|
+
return result, length
|
|
29
|
+
|
|
30
|
+
def _bdecode_int(raw: bytes) -> tuple[int, int]:
|
|
31
|
+
if raw.startswith(b"i0") and not raw.startswith(b"i0e"):
|
|
32
|
+
raise ValueError("Cannot parse leading zero in integer")
|
|
33
|
+
if raw.startswith(b"i-0"):
|
|
34
|
+
raise ValueError("Cannot parse negative zero in integer")
|
|
35
|
+
result = int(raw[1:raw.find(b"e")])
|
|
36
|
+
return result, raw.find(b"e") + 1
|
|
37
|
+
|
|
38
|
+
def _bdecode_list(raw: bytes) -> tuple[tuple, int]:
|
|
39
|
+
result = []
|
|
40
|
+
wip_raw = raw[1:]
|
|
41
|
+
total_strlen = 1
|
|
42
|
+
while wip_raw[0:1] != b"e":
|
|
43
|
+
step, strlen = _bdecode(wip_raw)
|
|
44
|
+
wip_raw = wip_raw[strlen:]
|
|
45
|
+
result.append(step)
|
|
46
|
+
total_strlen += strlen
|
|
47
|
+
return tuple(result), total_strlen + 1
|
|
48
|
+
|
|
49
|
+
def _bdecode_dict(raw: bytes) -> tuple[dict, int]:
|
|
50
|
+
result = {}
|
|
51
|
+
wip_raw = raw[1:]
|
|
52
|
+
total_strlen = 1
|
|
53
|
+
keys_list = []
|
|
54
|
+
while wip_raw[0:1] != b"e":
|
|
55
|
+
key, strlen = _bdecode(wip_raw)
|
|
56
|
+
keys_list.append(key)
|
|
57
|
+
wip_raw = wip_raw[strlen:]
|
|
58
|
+
total_strlen += strlen
|
|
59
|
+
val, strlen = _bdecode(wip_raw)
|
|
60
|
+
wip_raw = wip_raw[strlen:]
|
|
61
|
+
total_strlen += strlen
|
|
62
|
+
result[key] = val
|
|
63
|
+
if keys_list != sorted(keys_list):
|
|
64
|
+
raise ValueError(f"Bencoded dictionary {raw[:total_strlen+1]} not sorted")
|
|
65
|
+
return result, total_strlen + 1
|
|
66
|
+
|
|
67
|
+
def _bdecode(raw: bytes) -> tuple[Bencodable, int]:
|
|
68
|
+
if raw[0] in b"1234567890":
|
|
69
|
+
return _bdecode_string(raw)
|
|
70
|
+
elif raw[0:1] == b"i":
|
|
71
|
+
return _bdecode_int(raw)
|
|
72
|
+
elif raw[0:1] == b"l":
|
|
73
|
+
return _bdecode_list(raw)
|
|
74
|
+
elif raw[0:1] == b"d":
|
|
75
|
+
return _bdecode_dict(raw)
|
|
76
|
+
else:
|
|
77
|
+
raise ValueError(f"Unknown header in byte string b\"{chr(raw[0])}\"")
|
|
78
|
+
|
|
79
|
+
def bdecode_start(raw: bytes) -> tuple[Bencodable, bytes]:
|
|
80
|
+
"""Decode a bencoded bytestring, possibly with some data attached to the tail.
|
|
81
|
+
|
|
82
|
+
Return a tuple (decoded, tail).
|
|
83
|
+
"""
|
|
84
|
+
res, length = _bdecode(raw)
|
|
85
|
+
return res, raw[length:]
|
|
86
|
+
|
|
87
|
+
def bdecode(raw: bytes) -> Bencodable:
|
|
88
|
+
"""Decode a bencoded bytestring."""
|
|
89
|
+
res, length = _bdecode(raw)
|
|
90
|
+
if length != len(raw):
|
|
91
|
+
raise ValueError("Malformed bencoded data")
|
|
92
|
+
return res
|
heathrow/bitset.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
|
|
2
|
+
class Bitset:
|
|
3
|
+
"""A bitset. Limited in length"""
|
|
4
|
+
def __init__(self, length: int):
|
|
5
|
+
self._bitset = 0
|
|
6
|
+
self._length = length
|
|
7
|
+
|
|
8
|
+
def __len__(self):
|
|
9
|
+
return self._length
|
|
10
|
+
|
|
11
|
+
def __setitem__(self, index: int, val: bool):
|
|
12
|
+
if index < 0:
|
|
13
|
+
index = self._length - index
|
|
14
|
+
if not 0 <= index < self._length:
|
|
15
|
+
raise IndexError
|
|
16
|
+
if val:
|
|
17
|
+
self._bitset |= (1 << (len(self) - 1 - index))
|
|
18
|
+
else:
|
|
19
|
+
self._bitset &= (1 << len(self)) - 1 - (1 << (len(self) - 1 - index))
|
|
20
|
+
|
|
21
|
+
def __getitem__(self, index: int | slice):
|
|
22
|
+
if isinstance(index, slice):
|
|
23
|
+
start = index.start if index.start is not None else 0
|
|
24
|
+
stop = index.stop if index.stop is not None else self._length
|
|
25
|
+
step = index.step if index.step is not None else 1
|
|
26
|
+
|
|
27
|
+
start = start if start >= 0 else self._length + start
|
|
28
|
+
stop = stop if stop >= 0 else self._length + stop
|
|
29
|
+
|
|
30
|
+
if start >= stop:
|
|
31
|
+
return Bitset(length=0)
|
|
32
|
+
|
|
33
|
+
# We don't need this
|
|
34
|
+
if step != 1:
|
|
35
|
+
raise IndexError("Cannot efficiently iterate with step not equal to 1")
|
|
36
|
+
|
|
37
|
+
result = Bitset(stop - start)
|
|
38
|
+
value = (self._bitset >> (self._length - stop)) & ((1 << (stop - start)) - 1)
|
|
39
|
+
result._bitset = value
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
else:
|
|
43
|
+
if index < 0:
|
|
44
|
+
index = self._length - index
|
|
45
|
+
if not 0 <= index < self._length:
|
|
46
|
+
raise IndexError
|
|
47
|
+
return self._bitset & (1 << (len(self) - 1 - index)) > 0
|
|
48
|
+
|
|
49
|
+
def __invert__(self):
|
|
50
|
+
b = Bitset(self._length)
|
|
51
|
+
b._bitset = (1 << len(self)) - 1 - self._bitset
|
|
52
|
+
return b
|
|
53
|
+
|
|
54
|
+
def __and__(self, other):
|
|
55
|
+
if not isinstance(other, Bitset):
|
|
56
|
+
raise ValueError("Cannot operate on Bitset and non-Bitset")
|
|
57
|
+
if len(self) != len(other):
|
|
58
|
+
raise ValueError("Cannot operate on two Bitsets with different lengths")
|
|
59
|
+
b = Bitset(len(self))
|
|
60
|
+
b._bitset = self._bitset & other._bitset
|
|
61
|
+
return b
|
|
62
|
+
|
|
63
|
+
def __or__(self, other):
|
|
64
|
+
if not isinstance(other, Bitset):
|
|
65
|
+
raise ValueError("Cannot operate on Bitset and non-Bitset")
|
|
66
|
+
if len(self) != len(other):
|
|
67
|
+
raise ValueError("Cannot operate on two Bitsets with different lengths")
|
|
68
|
+
b = Bitset(len(self))
|
|
69
|
+
b._bitset = self._bitset | other._bitset
|
|
70
|
+
return b
|
|
71
|
+
|
|
72
|
+
def __hash__(self):
|
|
73
|
+
return hash(self._bitset, self._length)
|
|
74
|
+
|
|
75
|
+
def __bytes__(self):
|
|
76
|
+
padding = -self._length % 8
|
|
77
|
+
return (self._bitset << padding).to_bytes(length=(self._length + padding) // 8)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def popcount(self):
|
|
81
|
+
return self._bitset.bit_count()
|
|
82
|
+
|
|
83
|
+
def to_indices(self):
|
|
84
|
+
return (i for i in range(len(self)) if self[i])
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_bytes(cls, raw: bytes, length: int):
|
|
88
|
+
padding = -length % 8
|
|
89
|
+
b = cls(length)
|
|
90
|
+
b._bitset = int.from_bytes(raw) >> padding
|
|
91
|
+
return b
|
|
92
|
+
|
|
93
|
+
def __bool__(self):
|
|
94
|
+
return self._bitset > 0
|
heathrow/constants.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
PROTOCOL_NAME = "aquarius"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from heathrow.economy import Economy
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AlreadySubscribedError(ValueError):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Discovery(ABC):
|
|
11
|
+
"""Class for "Discovery" modules.
|
|
12
|
+
|
|
13
|
+
Discovery module is responsible for getting Reticulum links at which potential peers may reside.
|
|
14
|
+
|
|
15
|
+
It exposes two methods to subscribe and unsubscribe economies to individual fileset IDs,
|
|
16
|
+
as well as two methods to start and stop discovery.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def start(self):
|
|
21
|
+
"""Start any process loop(s) needed for the Discovery to run.
|
|
22
|
+
|
|
23
|
+
Do not block indefinitely.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def stop(self):
|
|
28
|
+
"""Stop the Discovery's process loop(s)."""
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def subscribe(self, fileset_id: str, economy: "Economy"):
|
|
32
|
+
"""Attach an Economy as a listener for new peers for fileset fileset_id.
|
|
33
|
+
|
|
34
|
+
Attaching multiple Economies to the same fileset_id should not be allowed. Raise an AlreadySubscribedError.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def unsubscribe(self, fileset_id: str):
|
|
39
|
+
"""Detach the current Economy from fileset_id.
|
|
40
|
+
|
|
41
|
+
Detaching a non-existent Economy from a fileset_id should not be allowed. Raise a KeyError.
|
|
42
|
+
"""
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from functools import partial
|
|
4
|
+
from threading import Condition, Thread
|
|
5
|
+
from time import sleep
|
|
6
|
+
|
|
7
|
+
import RNS
|
|
8
|
+
|
|
9
|
+
from heathrow.discovery import AlreadySubscribedError, Discovery
|
|
10
|
+
from heathrow.economy import Economy
|
|
11
|
+
from heathrow.wire import Direction, ProtocolError, Wire, WireFactory
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
PROTOCOL_NAME = "aquarius-protocol-v0" # From v0
|
|
17
|
+
ANNOUNCEMENT_INTERVAL_SECONDS = 15
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AnnounceHandler:
|
|
21
|
+
def __init__(self, fileset_id: str, discovery: "DestinationDiscovery"):
|
|
22
|
+
self.aspect_filter = f"{PROTOCOL_NAME}.{fileset_id}"
|
|
23
|
+
self.fileset_id = fileset_id
|
|
24
|
+
self.discovery = discovery
|
|
25
|
+
|
|
26
|
+
def received_announce(self, destination_hash, announced_identity, app_data):
|
|
27
|
+
if self.discovery.running:
|
|
28
|
+
logger.info(f"Received announce for fileset {self.fileset_id}.")
|
|
29
|
+
destination = RNS.Destination(announced_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, PROTOCOL_NAME, self.fileset_id)
|
|
30
|
+
RNS.Link(destination, established_callback=partial(self.discovery._new_link, Direction.OUTGOING, self.fileset_id))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Subscription:
|
|
35
|
+
economy: Economy
|
|
36
|
+
destination: RNS.Destination
|
|
37
|
+
announce_handler: AnnounceHandler
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DestinationDiscovery(Discovery):
|
|
41
|
+
"""Destination-based discovery algorithm.
|
|
42
|
+
|
|
43
|
+
Announce a separate destination for every
|
|
44
|
+
fileset every ANNOUNCEMENT_INTERVAL_SECONDS.
|
|
45
|
+
Also listen to similar announcements for all destinations.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, identity: RNS.Identity, wirefactory: WireFactory):
|
|
49
|
+
self.identity = identity
|
|
50
|
+
self.subscriptions = {}
|
|
51
|
+
self._thread = None
|
|
52
|
+
self.running = False
|
|
53
|
+
self.wirefactory = wirefactory
|
|
54
|
+
self._early_stop_condition = Condition()
|
|
55
|
+
|
|
56
|
+
def _new_link_subthread(self, wire: Wire, fileset_id: str):
|
|
57
|
+
"""Subthread for a new link. The library wants us to return fast, so we branch off."""
|
|
58
|
+
try:
|
|
59
|
+
fileset_id = wire.handshake()
|
|
60
|
+
except ProtocolError as exception:
|
|
61
|
+
logger.warning(f"Failed to establish a wire using {self.wirefactory} ({exception=})")
|
|
62
|
+
else:
|
|
63
|
+
self.subscriptions[fileset_id].economy.on_new_peer(wire)
|
|
64
|
+
|
|
65
|
+
def _new_link(self, direction: Direction, fileset_id: str, link: RNS.Link):
|
|
66
|
+
"""Callback for a new link."""
|
|
67
|
+
if direction is Direction.OUTGOING:
|
|
68
|
+
wire = self.wirefactory.create_outgoing(link, self.identity, fileset_id)
|
|
69
|
+
else:
|
|
70
|
+
wire = self.wirefactory.create_incoming(link, self.identity, [fileset_id])
|
|
71
|
+
Thread(target=partial(self._new_link_subthread, wire, fileset_id)).start()
|
|
72
|
+
|
|
73
|
+
def subscribe(self, fileset_id: str, economy: Economy):
|
|
74
|
+
if fileset_id in self.subscriptions:
|
|
75
|
+
raise AlreadySubscribedError(f"Cannot subscribe to a second economy for fileset_id {fileset_id}")
|
|
76
|
+
self.subscriptions[fileset_id] = Subscription(
|
|
77
|
+
economy=economy,
|
|
78
|
+
destination=RNS.Destination(
|
|
79
|
+
self.identity,
|
|
80
|
+
RNS.Destination.IN,
|
|
81
|
+
RNS.Destination.SINGLE,
|
|
82
|
+
PROTOCOL_NAME,
|
|
83
|
+
fileset_id
|
|
84
|
+
),
|
|
85
|
+
announce_handler=AnnounceHandler(fileset_id, self)
|
|
86
|
+
)
|
|
87
|
+
self.subscriptions[fileset_id].destination.set_link_established_callback(partial(self._new_link, Direction.INCOMING, fileset_id))
|
|
88
|
+
RNS.Transport.register_announce_handler(self.subscriptions[fileset_id].announce_handler)
|
|
89
|
+
|
|
90
|
+
def unsubscribe(self, fileset_id: str):
|
|
91
|
+
if fileset_id not in self.subscriptions:
|
|
92
|
+
raise KeyError(f"No economy to unsubscribe from fileset_id {fileset_id}")
|
|
93
|
+
logger.info(f"Unsubscribing from fileset {fileset_id}.")
|
|
94
|
+
RNS.Transport.deregister_announce_handler(self.subscriptions[fileset_id].announce_handler)
|
|
95
|
+
RNS.Transport.deregister_destination(self.subscriptions[fileset_id].destination)
|
|
96
|
+
del self.subscriptions[fileset_id]
|
|
97
|
+
|
|
98
|
+
def _run_announcements(self):
|
|
99
|
+
while self.running:
|
|
100
|
+
for fileset_id, subscription in self.subscriptions.items():
|
|
101
|
+
subscription.destination.announce()
|
|
102
|
+
logger.info(f"Announcing peer for fileset {fileset_id}.")
|
|
103
|
+
with self._early_stop_condition:
|
|
104
|
+
self._early_stop_condition.wait_for(lambda: not self.running, timeout=ANNOUNCEMENT_INTERVAL_SECONDS)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def start(self):
|
|
108
|
+
self.running = True
|
|
109
|
+
self._thread = Thread(target=self._run_announcements)
|
|
110
|
+
self._thread.start()
|
|
111
|
+
|
|
112
|
+
def stop(self):
|
|
113
|
+
self.running = False
|
|
114
|
+
with self._early_stop_condition:
|
|
115
|
+
self._early_stop_condition.notify()
|
|
116
|
+
if self._thread is not None:
|
|
117
|
+
self._thread.join()
|
|
118
|
+
self._thread = None
|
heathrow/driver/base.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
class Driver(ABC):
|
|
4
|
+
"""Class for the driver modules.
|
|
5
|
+
|
|
6
|
+
Driver modules are responsible for putting the program modules together.
|
|
7
|
+
|
|
8
|
+
It can be a TUI, a graphical application, or a daemon for example.
|
|
9
|
+
"""
|
|
10
|
+
@classmethod
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def main(cls):
|
|
13
|
+
"""Runs when the program starts."""
|
heathrow/driver/basic.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import RNS
|
|
7
|
+
|
|
8
|
+
from heathrow.discovery import DestinationDiscovery
|
|
9
|
+
from heathrow.economy import ProtoEconomy, ReferenceEconomy
|
|
10
|
+
from heathrow.fileset import V1Fileset
|
|
11
|
+
from heathrow.wire import BufferedWireFactory, ReticulatedWireFactory, ReticulatedV2WireFactory
|
|
12
|
+
|
|
13
|
+
from .base import Driver
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BasicDriver(Driver):
|
|
17
|
+
@classmethod
|
|
18
|
+
def main(cls):
|
|
19
|
+
level = logging.DEBUG if os.environ.get("HEATHROW_DEBUG", "0") == "1" else logging.INFO
|
|
20
|
+
logging.basicConfig(level=level, format="%(asctime)s [%(name)s:%(levelname)s] %(message)s")
|
|
21
|
+
metadata_dir = Path(os.environ.get("HEATHROW_METADATA_DIR", "./metadata"))
|
|
22
|
+
download_dir = Path(os.environ.get("HEATHROW_DOWNLOAD_DIR", "./download"))
|
|
23
|
+
fileset_id = bytes.fromhex(os.environ.get("HEATHROW_FILESET_ID", ""))
|
|
24
|
+
|
|
25
|
+
reticulum = RNS.Reticulum()
|
|
26
|
+
|
|
27
|
+
discovery = DestinationDiscovery(RNS.Identity(), BufferedWireFactory)
|
|
28
|
+
# economy = ProtoEconomy(V1Fileset(fileset_id, metadata_dir=metadata_dir, download_dir=download_dir))
|
|
29
|
+
economy = ReferenceEconomy(V1Fileset(fileset_id, metadata_dir=metadata_dir, download_dir=download_dir))
|
|
30
|
+
economy.start()
|
|
31
|
+
discovery.subscribe("urn:btih:" + fileset_id.hex(), economy)
|
|
32
|
+
discovery.start()
|
|
33
|
+
try:
|
|
34
|
+
while True:
|
|
35
|
+
time.sleep(1)
|
|
36
|
+
except KeyboardInterrupt:
|
|
37
|
+
print("Shutting down...")
|
|
38
|
+
economy.stop()
|
|
39
|
+
discovery.stop()
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import curses
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from importlib.metadata import version
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from threading import Condition, Thread
|
|
11
|
+
|
|
12
|
+
import RNS
|
|
13
|
+
|
|
14
|
+
import heathrow.discovery
|
|
15
|
+
import heathrow.wire
|
|
16
|
+
import heathrow.fileset
|
|
17
|
+
import heathrow.economy
|
|
18
|
+
from heathrow.bencode import bencode, bdecode
|
|
19
|
+
|
|
20
|
+
from ..base import Driver
|
|
21
|
+
from .constants import CONFIG_IDENTITY, CONFIG_METADATA, CONFIG_RESUME
|
|
22
|
+
from .util import string_to_fileset
|
|
23
|
+
from .windows import TUIWindow, SplashWindow
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class HeathrowDriverConfig:
|
|
31
|
+
config_dir: Path
|
|
32
|
+
tui: bool
|
|
33
|
+
debug: bool
|
|
34
|
+
discovery: type[heathrow.discovery.Discovery]
|
|
35
|
+
wirefactory: heathrow.wire.WireFactory
|
|
36
|
+
fileset: type[heathrow.fileset.Fileset]
|
|
37
|
+
economy: type[heathrow.economy.Economy]
|
|
38
|
+
targets: list[str]
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def parse_args(cls):
|
|
42
|
+
parser = argparse.ArgumentParser()
|
|
43
|
+
parser.add_argument("-c", "--config-directory", help="Heathrow config directory", type=Path, default=Path(os.path.expanduser("~/.config/heathrow")))
|
|
44
|
+
parser.add_argument("--tui", help="Show the TUI", action=argparse.BooleanOptionalAction, default=True)
|
|
45
|
+
parser.add_argument("--debug", help="Show extra debug information", action=argparse.BooleanOptionalAction, default=True)
|
|
46
|
+
parser.add_argument("--discovery", help="Discovery module to use", choices=[i.removesuffix("Discovery") for i in heathrow.discovery.__all__ if i.endswith("Discovery") and i != "Discovery"], default="Destination")
|
|
47
|
+
parser.add_argument("--wire", help="Wire module to use", choices=[i.removesuffix("WireFactory") for i in heathrow.wire.__all__ if i.endswith("WireFactory") and i != "WireFactory"], default="LTEPBuffered")
|
|
48
|
+
parser.add_argument("--fileset", help="Fileset module to use", choices=[i.removesuffix("Fileset") for i in heathrow.fileset.__all__ if i.endswith("Fileset") and i != "Fileset"], default="V1")
|
|
49
|
+
parser.add_argument("--economy", help="Economy module to use", choices=[i.removesuffix("Economy") for i in heathrow.economy.__all__ if i.endswith("Economy") and i != "Economy"], default="Reference")
|
|
50
|
+
parser.add_argument("targets", help="Start downloading these magnet link / file URIs", nargs="*")
|
|
51
|
+
args = parser.parse_args()
|
|
52
|
+
discovery = getattr(heathrow.discovery, args.discovery + "Discovery")
|
|
53
|
+
wirefactory = getattr(heathrow.wire, args.wirefactory + "WireFactory")
|
|
54
|
+
fileset = getattr(heathrow.fileset, args.fileset + "Fileset")
|
|
55
|
+
economy = getattr(heathrow.economy, args.economy + "Economy")
|
|
56
|
+
return cls(config_dir=args.config_directory, tui=args.tui, debug=args.debug, discovery=discovery, wirefactory=wirefactory, fileset=fileset, economy=economy, targets=args.targets)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
TEXT_LOGO = """
|
|
60
|
+
▄ ▄ ▄▄▄▄ ▄▄▄▄▄ ▄▄▄▄▄▄▄ ▄ ▄ ▄▄▄▄ ▄▄▄▄▄ ▄ ▄
|
|
61
|
+
▄██▄▄█▀ ▄█▄▄ ▄██▄▄█▀ ▄█▀ ▄██▄▄█▀ ▄██▄ ▀ ▄█▀ ▄█▀ ▄█▀▄█ ▄█▀
|
|
62
|
+
█▀ █▀ ▄█▄▄ █▀ █▀ ▄█▀ █▀ █▀ █▀ █▀ ██▄▄█▀ ██▄██▄█▀
|
|
63
|
+
""".strip("\n")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
MIN_COLS = 80
|
|
67
|
+
MIN_LINES = 24
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ScreenSizeErrorWindow(TUIWindow):
|
|
71
|
+
def draw(self):
|
|
72
|
+
self._window.clear()
|
|
73
|
+
cursor_y = (curses.LINES - len(TEXT_LOGO.split("\n"))) // 2 - 1
|
|
74
|
+
cursor_x = (curses.COLS - max(len(i) for i in TEXT_LOGO.split("\n"))) // 2
|
|
75
|
+
try:
|
|
76
|
+
for y, line in enumerate(TEXT_LOGO.split("\n")):
|
|
77
|
+
self._window.addstr(y, 0, line)
|
|
78
|
+
self._window.addstr(len(TEXT_LOGO.split("\n")), 0, f"Screen size {MIN_COLS}x{MIN_LINES} or more required for TUI mode.")
|
|
79
|
+
self._window.addstr(len(TEXT_LOGO.split("\n")) + 1, 0, f"(F10 to quit)")
|
|
80
|
+
except curses.error:
|
|
81
|
+
pass
|
|
82
|
+
self._window.refresh()
|
|
83
|
+
|
|
84
|
+
def handle_key(self, key: str | None):
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class HeathrowDriver(Driver):
|
|
89
|
+
@classmethod
|
|
90
|
+
def main(cls):
|
|
91
|
+
config = HeathrowDriverConfig.parse_args()
|
|
92
|
+
reticulum = RNS.Reticulum(loglevel=0)
|
|
93
|
+
self = cls(config)
|
|
94
|
+
self.run()
|
|
95
|
+
|
|
96
|
+
def create_resume_file(self, fileset: heathrow.fileset.Fileset):
|
|
97
|
+
resume_path = self.config.config_dir / CONFIG_RESUME / (fileset.fileset_id.replace(":", "_") + ".resume")
|
|
98
|
+
download_dir = fileset
|
|
99
|
+
if resume_path.exists():
|
|
100
|
+
pass # We have it already
|
|
101
|
+
else:
|
|
102
|
+
with open(resume_path, "wb") as f:
|
|
103
|
+
f.write(bencode({b"destination": str(fileset.download_directory.absolute()).encode()}))
|
|
104
|
+
|
|
105
|
+
def load_fileset_from_resume_file(self, resume_path: Path):
|
|
106
|
+
try:
|
|
107
|
+
with open(resume_path, "rb") as f:
|
|
108
|
+
resume = f.read()
|
|
109
|
+
parsed_resume = bdecode(resume)
|
|
110
|
+
download_dir = parsed_resume[b"destination"]
|
|
111
|
+
resume_name = resume_path.name.removesuffix(".resume")
|
|
112
|
+
metadata_path = self.config.config_dir / CONFIG_METADATA / (resume_name + ".torrent")
|
|
113
|
+
try:
|
|
114
|
+
fileset = self.config.fileset.from_file(metadata_path.read_bytes(), Path(download_dir.decode()), self.config.config_dir / CONFIG_METADATA)
|
|
115
|
+
except FileNotFoundError:
|
|
116
|
+
inferred_id = resume_name.replace("_", ":")
|
|
117
|
+
if not inferred_id.startswith("urn:"):
|
|
118
|
+
# TODO fileset V2
|
|
119
|
+
inferred_id = "urn:btih:" + inferred_id
|
|
120
|
+
fileset = self.config.fileset.from_id(inferred_id, Path(download_dir.decode()), self.config.config_dir / CONFIG_METADATA)
|
|
121
|
+
return fileset
|
|
122
|
+
except Exception as e:
|
|
123
|
+
raise ValueError(f"Could not load resume file at path {resume_path}") from e
|
|
124
|
+
|
|
125
|
+
def load_tracked_filesets(self):
|
|
126
|
+
filesets = []
|
|
127
|
+
for path in (self.config.config_dir / CONFIG_RESUME).iterdir():
|
|
128
|
+
if path.name.endswith(".resume"):
|
|
129
|
+
try:
|
|
130
|
+
filesets.append(self.load_fileset_from_resume_file(path))
|
|
131
|
+
except:
|
|
132
|
+
logger.warning(f"Could not resume download at {path}", exc_info=True)
|
|
133
|
+
for cliarg in self.config.targets:
|
|
134
|
+
fs = string_to_fileset(cliarg, self.config.fileset, Path(os.path.expanduser("~/Downloads")), self.config.config_dir / CONFIG_METADATA)
|
|
135
|
+
if fs is not None:
|
|
136
|
+
self._driver.create_resume_file(fs)
|
|
137
|
+
self._driver.add_fileset(fs)
|
|
138
|
+
return filesets
|
|
139
|
+
|
|
140
|
+
def add_fileset(self, fileset):
|
|
141
|
+
if fileset.fileset_id in self._tracked_filesets:
|
|
142
|
+
return
|
|
143
|
+
self._tracked_filesets[fileset.fileset_id] = fileset
|
|
144
|
+
self._economies[fileset.fileset_id] = self.config.economy(fileset)
|
|
145
|
+
self._discovery.subscribe(fileset.fileset_id, self._economies[fileset.fileset_id])
|
|
146
|
+
self._economies[fileset.fileset_id].start()
|
|
147
|
+
|
|
148
|
+
def init_filesets_and_economies(self):
|
|
149
|
+
"""Separate from init function to call during e.g. the splash screen."""
|
|
150
|
+
self._tracked_filesets = {i.fileset_id: i for i in self.load_tracked_filesets()}
|
|
151
|
+
self._economies = {k: self.config.economy(v) for k, v in self._tracked_filesets.items()} # TODO add constructor to interface
|
|
152
|
+
for k, v in self._economies.items():
|
|
153
|
+
v.start()
|
|
154
|
+
self._discovery.subscribe(k, v)
|
|
155
|
+
|
|
156
|
+
def __init__(self, config):
|
|
157
|
+
self.config = config
|
|
158
|
+
(self.config.config_dir / CONFIG_METADATA).mkdir(parents=True, exist_ok=True)
|
|
159
|
+
(self.config.config_dir / CONFIG_RESUME).mkdir(parents=True, exist_ok=True)
|
|
160
|
+
logging.basicConfig(filename=self.config.config_dir / 'heathrow.log', level=logging.DEBUG, format="%(asctime)s [%(name)s:%(levelname)s] %(message)s")
|
|
161
|
+
if (identity := RNS.Identity.from_file(self.config.config_dir / CONFIG_IDENTITY)) is None:
|
|
162
|
+
identity = RNS.Identity()
|
|
163
|
+
identity.to_file(self.config.config_dir / CONFIG_IDENTITY)
|
|
164
|
+
(self.config.config_dir / CONFIG_IDENTITY).chmod(0o600)
|
|
165
|
+
self._identity = identity
|
|
166
|
+
self._discovery = self.config.discovery(self._identity, self.config.wirefactory) # TODO add constructor to interface
|
|
167
|
+
self._tracked_filesets_loaded = False
|
|
168
|
+
self._tracked_filesets = {}
|
|
169
|
+
self._economies = {}
|
|
170
|
+
self._download_speeds = defaultdict(float)
|
|
171
|
+
self._upload_speeds = defaultdict(float)
|
|
172
|
+
self._speedtest_stop = False
|
|
173
|
+
self._speedtest_thread = Thread(target=self._speedtest_loop)
|
|
174
|
+
self._early_stop_condition = Condition()
|
|
175
|
+
self._windows = []
|
|
176
|
+
|
|
177
|
+
def _speedtest_loop(self):
|
|
178
|
+
CURRENT_EXP = 0.3
|
|
179
|
+
last_downloads = defaultdict(int)
|
|
180
|
+
last_uploads = defaultdict(int)
|
|
181
|
+
while not self._speedtest_stop:
|
|
182
|
+
for economy in list(self._economies.keys()):
|
|
183
|
+
observed_down = self._economies[economy].status.downloaded_bytes - last_downloads[economy]
|
|
184
|
+
observed_up = self._economies[economy].status.uploaded_bytes - last_uploads[economy]
|
|
185
|
+
self._download_speeds[economy] = observed_down * CURRENT_EXP + self._download_speeds[economy] * (1 - CURRENT_EXP)
|
|
186
|
+
self._upload_speeds[economy] = observed_up * CURRENT_EXP + self._upload_speeds[economy] * (1 - CURRENT_EXP)
|
|
187
|
+
last_downloads[economy] = self._economies[economy].status.downloaded_bytes
|
|
188
|
+
last_uploads[economy] = self._economies[economy].status.uploaded_bytes
|
|
189
|
+
with self._early_stop_condition:
|
|
190
|
+
self._early_stop_condition.wait_for(lambda: self._speedtest_stop, timeout=1)
|
|
191
|
+
|
|
192
|
+
def run(self):
|
|
193
|
+
self._discovery.start()
|
|
194
|
+
if self.config.tui:
|
|
195
|
+
curses.wrapper(self.curses_main)
|
|
196
|
+
else:
|
|
197
|
+
self.init_filesets_and_economies()
|
|
198
|
+
self._driver._tracked_filesets_loaded = True
|
|
199
|
+
try:
|
|
200
|
+
while True:
|
|
201
|
+
time.sleep(1)
|
|
202
|
+
except KeyboardInterrupt:
|
|
203
|
+
pass
|
|
204
|
+
print(f"Thank you for using Heathrow {version('heathrow')}!")
|
|
205
|
+
|
|
206
|
+
def curses_main(self, stdscr):
|
|
207
|
+
stdscr.clear()
|
|
208
|
+
self._speedtest_thread.start()
|
|
209
|
+
self._windows.append(SplashWindow(stdscr, self))
|
|
210
|
+
size_error = ScreenSizeErrorWindow(stdscr, self)
|
|
211
|
+
curses.halfdelay(30)
|
|
212
|
+
curses.mousemask(curses.BUTTON1_CLICKED)
|
|
213
|
+
while True:
|
|
214
|
+
curses.update_lines_cols()
|
|
215
|
+
if self._windows:
|
|
216
|
+
if curses.COLS < MIN_COLS or curses.LINES < MIN_LINES:
|
|
217
|
+
size_error.draw()
|
|
218
|
+
else:
|
|
219
|
+
for window in self._windows:
|
|
220
|
+
window.draw()
|
|
221
|
+
try:
|
|
222
|
+
key = stdscr.getkey()
|
|
223
|
+
except curses.error:
|
|
224
|
+
key = None
|
|
225
|
+
active_window = len(self._windows) - 1
|
|
226
|
+
val = self._windows[active_window].handle_key(key)
|
|
227
|
+
if not val:
|
|
228
|
+
del self._windows[active_window]
|
|
229
|
+
else:
|
|
230
|
+
break
|
|
231
|
+
|
|
232
|
+
self._speedtest_stop = True
|
|
233
|
+
with self._early_stop_condition:
|
|
234
|
+
self._early_stop_condition.notify()
|
|
235
|
+
for economy in self._economies.values():
|
|
236
|
+
economy.stop()
|
|
237
|
+
self._discovery.stop()
|
|
238
|
+
self._speedtest_thread.join()
|