dev-null-tty 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.
- dev_null_tty/__init__.py +758 -0
- dev_null_tty/__main__.py +5 -0
- dev_null_tty/api.py +532 -0
- dev_null_tty/desktop.py +157 -0
- dev_null_tty/styles.tcss +185 -0
- dev_null_tty-0.1.0.dist-info/METADATA +99 -0
- dev_null_tty-0.1.0.dist-info/RECORD +9 -0
- dev_null_tty-0.1.0.dist-info/WHEEL +4 -0
- dev_null_tty-0.1.0.dist-info/entry_points.txt +3 -0
dev_null_tty/__main__.py
ADDED
dev_null_tty/api.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""Signed requests to the /dev/null mobile API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
import platform
|
|
10
|
+
import secrets
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
import uuid
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Literal
|
|
21
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
22
|
+
|
|
23
|
+
API_ORIGIN = "https://dev-null.sh"
|
|
24
|
+
SIGNATURE_NAMESPACE = "devnull-api"
|
|
25
|
+
DEVICE_DIRECTORY = Path.home() / ".dev-null-tui"
|
|
26
|
+
DEVICE_NAME = f"dev-null-tty on {platform.system()}"
|
|
27
|
+
REDIRECT_URI = "devnull://auth/callback"
|
|
28
|
+
LogName = Literal["following", "latest", "trending"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class ApiUser:
|
|
33
|
+
username: str
|
|
34
|
+
name: str | None
|
|
35
|
+
avatar_url: str | None
|
|
36
|
+
bio: str | None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ApiCommit:
|
|
41
|
+
id: str
|
|
42
|
+
username: str
|
|
43
|
+
author_name: str | None
|
|
44
|
+
body: str
|
|
45
|
+
created_at: str
|
|
46
|
+
stars_count: int
|
|
47
|
+
replies_count: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class ApiLog:
|
|
52
|
+
commits: tuple[ApiCommit, ...]
|
|
53
|
+
next_cursor: str | None
|
|
54
|
+
is_ranked: bool
|
|
55
|
+
new_commits_count: int
|
|
56
|
+
suggestions: tuple[ApiUser, ...]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class DeviceAuthorization:
|
|
61
|
+
key_path: Path
|
|
62
|
+
state: str
|
|
63
|
+
code_verifier: str
|
|
64
|
+
authorization_url: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ApiError(Exception):
|
|
68
|
+
"""An expected error returned by the signed API."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, message: str, code: str | None = None) -> None:
|
|
71
|
+
super().__init__(message)
|
|
72
|
+
self.code = code
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
76
|
+
def redirect_request(self, request, response, code, message, headers, new_url):
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _load_existing_device_keypair() -> Path | None:
|
|
81
|
+
private_key = DEVICE_DIRECTORY / "id_ed25519"
|
|
82
|
+
public_key = DEVICE_DIRECTORY / "id_ed25519.pub"
|
|
83
|
+
if private_key.is_symlink() or public_key.is_symlink():
|
|
84
|
+
raise ApiError("The device keypair must not use symbolic links.")
|
|
85
|
+
private_exists = private_key.is_file()
|
|
86
|
+
public_exists = public_key.is_file()
|
|
87
|
+
if private_key.exists() != private_exists or public_key.exists() != public_exists:
|
|
88
|
+
raise ApiError("The device-key files must be regular files.")
|
|
89
|
+
if private_exists != public_exists:
|
|
90
|
+
raise ApiError("The saved device keypair is incomplete; repair or remove it before signing in.")
|
|
91
|
+
if not private_exists:
|
|
92
|
+
return None
|
|
93
|
+
try:
|
|
94
|
+
private_key.chmod(0o600)
|
|
95
|
+
public_key.chmod(0o644)
|
|
96
|
+
_device_fingerprint(private_key)
|
|
97
|
+
except ApiError:
|
|
98
|
+
raise
|
|
99
|
+
except OSError as error:
|
|
100
|
+
raise ApiError("Could not secure the app's existing device keypair.") from error
|
|
101
|
+
return private_key
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def existing_device_identity() -> Path | None:
|
|
105
|
+
"""Return the saved app key if one exists, without creating a new identity."""
|
|
106
|
+
|
|
107
|
+
if DEVICE_DIRECTORY.is_symlink():
|
|
108
|
+
raise ApiError("The app-data directory must not be a symbolic link.")
|
|
109
|
+
if not DEVICE_DIRECTORY.exists():
|
|
110
|
+
return None
|
|
111
|
+
ensure_device_directory()
|
|
112
|
+
return _load_existing_device_keypair()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def remove_device_identity() -> None:
|
|
116
|
+
"""Remove only the app's local SSH keypair, leaving other app data untouched."""
|
|
117
|
+
|
|
118
|
+
if DEVICE_DIRECTORY.is_symlink():
|
|
119
|
+
raise ApiError("The app-data directory must not be a symbolic link.")
|
|
120
|
+
if not DEVICE_DIRECTORY.exists():
|
|
121
|
+
return
|
|
122
|
+
ensure_device_directory()
|
|
123
|
+
private_key = DEVICE_DIRECTORY / "id_ed25519"
|
|
124
|
+
public_key = DEVICE_DIRECTORY / "id_ed25519.pub"
|
|
125
|
+
for key_file in (private_key, public_key):
|
|
126
|
+
if key_file.is_symlink() or (key_file.exists() and not key_file.is_file()):
|
|
127
|
+
raise ApiError("The device-key files must be regular files before logging out.")
|
|
128
|
+
try:
|
|
129
|
+
public_key.unlink(missing_ok=True)
|
|
130
|
+
private_key.unlink(missing_ok=True)
|
|
131
|
+
except OSError as error:
|
|
132
|
+
raise ApiError("Could not remove the app's local device keypair.") from error
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def resolve_ssh_identity() -> Path:
|
|
136
|
+
"""Create or return this app's dedicated Ed25519 device keypair."""
|
|
137
|
+
|
|
138
|
+
ensure_device_directory()
|
|
139
|
+
existing_key = _load_existing_device_keypair()
|
|
140
|
+
if existing_key is not None:
|
|
141
|
+
return existing_key
|
|
142
|
+
|
|
143
|
+
private_key = DEVICE_DIRECTORY / "id_ed25519"
|
|
144
|
+
ssh_keygen = shutil.which("ssh-keygen")
|
|
145
|
+
if ssh_keygen is None:
|
|
146
|
+
raise ApiError("ssh-keygen is required to create the app's device key.")
|
|
147
|
+
try:
|
|
148
|
+
subprocess.run(
|
|
149
|
+
[
|
|
150
|
+
ssh_keygen,
|
|
151
|
+
"-q",
|
|
152
|
+
"-t",
|
|
153
|
+
"ed25519",
|
|
154
|
+
"-N",
|
|
155
|
+
"",
|
|
156
|
+
"-C",
|
|
157
|
+
"dev-null-tty",
|
|
158
|
+
"-f",
|
|
159
|
+
str(private_key),
|
|
160
|
+
],
|
|
161
|
+
check=True,
|
|
162
|
+
capture_output=True,
|
|
163
|
+
timeout=20,
|
|
164
|
+
)
|
|
165
|
+
private_key.chmod(0o600)
|
|
166
|
+
private_key.with_suffix(".pub").chmod(0o644)
|
|
167
|
+
_device_fingerprint(private_key)
|
|
168
|
+
except ApiError:
|
|
169
|
+
raise
|
|
170
|
+
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
|
|
171
|
+
raise ApiError("Could not create or secure the app's device keypair.") from error
|
|
172
|
+
return private_key
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def ensure_device_directory() -> None:
|
|
176
|
+
"""Create the private app-data directory and enforce owner-only access."""
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
DEVICE_DIRECTORY.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
180
|
+
if DEVICE_DIRECTORY.is_symlink() or not DEVICE_DIRECTORY.is_dir():
|
|
181
|
+
raise ApiError("The app-data directory must be a real directory.")
|
|
182
|
+
DEVICE_DIRECTORY.chmod(0o700)
|
|
183
|
+
except ApiError:
|
|
184
|
+
raise
|
|
185
|
+
except OSError as error:
|
|
186
|
+
raise ApiError("Could not create or secure ~/.dev-null-tui.") from error
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _device_fingerprint(key_path: Path) -> str:
|
|
190
|
+
ssh_keygen = shutil.which("ssh-keygen")
|
|
191
|
+
if ssh_keygen is None:
|
|
192
|
+
raise ApiError("ssh-keygen is required to identify the device key.")
|
|
193
|
+
try:
|
|
194
|
+
result = subprocess.run(
|
|
195
|
+
[ssh_keygen, "-lf", f"{key_path}.pub"],
|
|
196
|
+
check=True,
|
|
197
|
+
capture_output=True,
|
|
198
|
+
text=True,
|
|
199
|
+
timeout=20,
|
|
200
|
+
)
|
|
201
|
+
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
|
|
202
|
+
raise ApiError("Could not read the app's device-key fingerprint.") from error
|
|
203
|
+
parts = result.stdout.split()
|
|
204
|
+
if len(parts) < 3 or not parts[1].startswith("SHA256:") or parts[-1] != "(ED25519)":
|
|
205
|
+
raise ApiError("The app's saved device key is not a valid Ed25519 key.")
|
|
206
|
+
return parts[1]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def create_device_authorization() -> DeviceAuthorization:
|
|
210
|
+
"""Create an OAuth-style authorization URL and an in-memory PKCE verifier."""
|
|
211
|
+
|
|
212
|
+
key_path = resolve_ssh_identity()
|
|
213
|
+
fingerprint = _device_fingerprint(key_path)
|
|
214
|
+
code_verifier = secrets.token_urlsafe(32)
|
|
215
|
+
code_challenge = base64.urlsafe_b64encode(
|
|
216
|
+
hashlib.sha256(code_verifier.encode("ascii")).digest()
|
|
217
|
+
).rstrip(b"=").decode("ascii")
|
|
218
|
+
state = secrets.token_urlsafe(32)
|
|
219
|
+
query = urlencode(
|
|
220
|
+
{
|
|
221
|
+
"fingerprint": fingerprint,
|
|
222
|
+
"device_name": DEVICE_NAME,
|
|
223
|
+
"code_challenge": code_challenge,
|
|
224
|
+
"code_challenge_method": "S256",
|
|
225
|
+
"state": state,
|
|
226
|
+
"redirect_uri": REDIRECT_URI,
|
|
227
|
+
}
|
|
228
|
+
)
|
|
229
|
+
return DeviceAuthorization(
|
|
230
|
+
key_path=key_path,
|
|
231
|
+
state=state,
|
|
232
|
+
code_verifier=code_verifier,
|
|
233
|
+
authorization_url=f"{API_ORIGIN}/mobile/authorize?{query}",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def authorization_callback_state(callback_url: str) -> tuple[str, dict[str, list[str]]]:
|
|
238
|
+
"""Validate the custom-scheme callback's shape and return its state and query."""
|
|
239
|
+
try:
|
|
240
|
+
callback = urlsplit(callback_url.strip())
|
|
241
|
+
query = parse_qs(callback.query, keep_blank_values=True, strict_parsing=True)
|
|
242
|
+
except ValueError as error:
|
|
243
|
+
raise ApiError("The authorization callback URL is malformed.") from error
|
|
244
|
+
if callback.scheme != "devnull" or callback.netloc != "auth" or callback.path != "/callback" or callback.fragment:
|
|
245
|
+
raise ApiError("Paste the full devnull://auth/callback URL from the browser.")
|
|
246
|
+
states = query.get("state", [])
|
|
247
|
+
if len(states) != 1 or not states[0].isascii():
|
|
248
|
+
raise ApiError("The authorization callback must contain exactly one valid state.")
|
|
249
|
+
return states[0], query
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def authorization_code_from_callback(callback_url: str, expected_state: str) -> str:
|
|
253
|
+
"""Validate the custom-scheme callback and return its one-time code."""
|
|
254
|
+
|
|
255
|
+
state, query = authorization_callback_state(callback_url)
|
|
256
|
+
if not hmac.compare_digest(state, expected_state):
|
|
257
|
+
raise ApiError("The authorization state did not match. Start sign-in again.")
|
|
258
|
+
errors = query.get("error", [])
|
|
259
|
+
if errors:
|
|
260
|
+
if len(errors) != 1:
|
|
261
|
+
raise ApiError("The authorization callback is malformed.")
|
|
262
|
+
if errors[0] == "access_denied":
|
|
263
|
+
raise ApiError("Sign-in was canceled in the browser.")
|
|
264
|
+
raise ApiError(f"Authorization failed: {errors[0]}")
|
|
265
|
+
codes = query.get("code", [])
|
|
266
|
+
if len(codes) != 1 or not codes[0]:
|
|
267
|
+
raise ApiError("The authorization callback did not contain one code.")
|
|
268
|
+
return codes[0]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _sign(message: bytes, key_path: Path) -> str:
|
|
272
|
+
ssh_keygen = shutil.which("ssh-keygen")
|
|
273
|
+
if ssh_keygen is None:
|
|
274
|
+
raise ApiError("ssh-keygen is required to sign /dev/null API requests.")
|
|
275
|
+
|
|
276
|
+
with tempfile.TemporaryDirectory(prefix="dev-null-signature-") as temporary_directory:
|
|
277
|
+
payload_path = Path(temporary_directory) / "request"
|
|
278
|
+
payload_path.write_bytes(message)
|
|
279
|
+
payload_path.chmod(0o600)
|
|
280
|
+
command = [
|
|
281
|
+
ssh_keygen,
|
|
282
|
+
"-Y",
|
|
283
|
+
"sign",
|
|
284
|
+
"-n",
|
|
285
|
+
SIGNATURE_NAMESPACE,
|
|
286
|
+
"-f",
|
|
287
|
+
str(key_path),
|
|
288
|
+
str(payload_path),
|
|
289
|
+
]
|
|
290
|
+
try:
|
|
291
|
+
subprocess.run(command, check=True, capture_output=True, timeout=20)
|
|
292
|
+
except subprocess.CalledProcessError as error:
|
|
293
|
+
if b"incorrect passphrase" in error.stderr.lower() or b"passphrase" in error.stderr.lower():
|
|
294
|
+
raise ApiError(
|
|
295
|
+
"The SSH key requires a passphrase. Unlock it with ssh-agent and retry."
|
|
296
|
+
) from error
|
|
297
|
+
raise ApiError("ssh-keygen could not sign the API request.") from error
|
|
298
|
+
except subprocess.TimeoutExpired as error:
|
|
299
|
+
raise ApiError("ssh-keygen timed out while signing the API request.") from error
|
|
300
|
+
|
|
301
|
+
signature_path = payload_path.with_name(f"{payload_path.name}.sig")
|
|
302
|
+
try:
|
|
303
|
+
armored_signature = signature_path.read_text(encoding="ascii")
|
|
304
|
+
except OSError as error:
|
|
305
|
+
raise ApiError("ssh-keygen did not produce a signature.") from error
|
|
306
|
+
signature = "".join(
|
|
307
|
+
line.strip()
|
|
308
|
+
for line in armored_signature.splitlines()
|
|
309
|
+
if not line.startswith("-----")
|
|
310
|
+
)
|
|
311
|
+
if not signature:
|
|
312
|
+
raise ApiError("ssh-keygen produced an empty signature.")
|
|
313
|
+
return signature
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def signed_headers(method: str, path_and_query: str, body: bytes, key_path: Path) -> dict[str, str]:
|
|
317
|
+
timestamp = str(int(time.time()))
|
|
318
|
+
nonce = uuid.uuid4().hex
|
|
319
|
+
message = b"\n".join(
|
|
320
|
+
(timestamp.encode(), nonce.encode(), method.upper().encode(), path_and_query.encode(), body)
|
|
321
|
+
)
|
|
322
|
+
return {
|
|
323
|
+
"X-Devnull-Timestamp": timestamp,
|
|
324
|
+
"X-Devnull-Nonce": nonce,
|
|
325
|
+
"X-SSH-Signature": _sign(message, key_path),
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class SignedApiClient:
|
|
330
|
+
"""A minimal client for signed /dev/null API calls."""
|
|
331
|
+
|
|
332
|
+
def __init__(self, key_path: Path, origin: str = API_ORIGIN) -> None:
|
|
333
|
+
parsed_origin = urlsplit(origin)
|
|
334
|
+
if parsed_origin.scheme != "https" or not parsed_origin.netloc or parsed_origin.path:
|
|
335
|
+
raise ValueError("The API origin must be an HTTPS origin.")
|
|
336
|
+
self.key_path = key_path
|
|
337
|
+
self.origin = origin.rstrip("/")
|
|
338
|
+
self._opener = urllib.request.build_opener(_NoRedirect)
|
|
339
|
+
|
|
340
|
+
def get_current_user(self) -> ApiUser:
|
|
341
|
+
payload = self._request("GET", "/api/v1/me")
|
|
342
|
+
return self._user_from_payload(payload)
|
|
343
|
+
|
|
344
|
+
def get_log(self, log_name: LogName, cursor: str | None = None) -> ApiLog:
|
|
345
|
+
if log_name not in {"following", "latest", "trending"}:
|
|
346
|
+
raise ValueError(f"Unsupported log: {log_name}")
|
|
347
|
+
path = f"/api/v1/log/{log_name}"
|
|
348
|
+
if cursor is not None:
|
|
349
|
+
path = f"{path}?{urlencode({'cursor': cursor})}"
|
|
350
|
+
payload = self._request("GET", path)
|
|
351
|
+
return self._log_from_payload(payload)
|
|
352
|
+
|
|
353
|
+
def create_commit(
|
|
354
|
+
self,
|
|
355
|
+
body: str,
|
|
356
|
+
parent_id: str | None = None,
|
|
357
|
+
quoted_commit_id: str | None = None,
|
|
358
|
+
) -> ApiCommit:
|
|
359
|
+
if parent_id is not None and quoted_commit_id is not None:
|
|
360
|
+
raise ValueError("A commit cannot be both a reply and a quote.")
|
|
361
|
+
data = {"body": body}
|
|
362
|
+
if parent_id is not None:
|
|
363
|
+
data["parent_id"] = parent_id
|
|
364
|
+
if quoted_commit_id is not None:
|
|
365
|
+
data["quoted_commit_id"] = quoted_commit_id
|
|
366
|
+
request_body = json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
367
|
+
payload = self._request("POST", "/api/v1/commits", request_body)
|
|
368
|
+
try:
|
|
369
|
+
commit = payload["data"]
|
|
370
|
+
except KeyError as error:
|
|
371
|
+
raise ApiError("The API returned an unexpected created-commit response.") from error
|
|
372
|
+
return self._commit_from_payload(commit)
|
|
373
|
+
|
|
374
|
+
def redeem_device(self, code: str, code_verifier: str) -> ApiUser:
|
|
375
|
+
body = json.dumps(
|
|
376
|
+
{"code": code, "code_verifier": code_verifier},
|
|
377
|
+
separators=(",", ":"),
|
|
378
|
+
).encode("utf-8")
|
|
379
|
+
payload = self._request("POST", "/api/v1/devices", body)
|
|
380
|
+
return self._user_from_payload(payload)
|
|
381
|
+
|
|
382
|
+
@staticmethod
|
|
383
|
+
def _user_from_payload(payload: dict) -> ApiUser:
|
|
384
|
+
try:
|
|
385
|
+
data = payload["data"]
|
|
386
|
+
return ApiUser(
|
|
387
|
+
username=data["username"],
|
|
388
|
+
name=data["name"],
|
|
389
|
+
avatar_url=data.get("avatar_url"),
|
|
390
|
+
bio=data.get("bio"),
|
|
391
|
+
)
|
|
392
|
+
except (KeyError, TypeError) as error:
|
|
393
|
+
raise ApiError("The API returned an unexpected current-user response.") from error
|
|
394
|
+
|
|
395
|
+
@staticmethod
|
|
396
|
+
def _log_from_payload(payload: dict) -> ApiLog:
|
|
397
|
+
try:
|
|
398
|
+
entries = payload["data"]
|
|
399
|
+
if not isinstance(entries, list):
|
|
400
|
+
raise TypeError
|
|
401
|
+
meta = payload.get("meta", {})
|
|
402
|
+
if not isinstance(meta, dict):
|
|
403
|
+
raise TypeError
|
|
404
|
+
commits = tuple(SignedApiClient._commit_from_payload(entry) for entry in entries)
|
|
405
|
+
next_cursor = meta.get("next_cursor", payload.get("next_cursor"))
|
|
406
|
+
is_ranked = meta.get("is_ranked", payload.get("is_ranked", False))
|
|
407
|
+
new_commits_count = meta.get(
|
|
408
|
+
"new_commits_count", payload.get("new_commits_count", 0)
|
|
409
|
+
)
|
|
410
|
+
suggestions_data = meta.get("suggestions", payload.get("suggestions", []))
|
|
411
|
+
if not isinstance(suggestions_data, list):
|
|
412
|
+
raise TypeError
|
|
413
|
+
suggestions = tuple(
|
|
414
|
+
SignedApiClient._suggestion_from_payload(suggestion)
|
|
415
|
+
for suggestion in suggestions_data
|
|
416
|
+
)
|
|
417
|
+
if next_cursor is not None and not isinstance(next_cursor, str):
|
|
418
|
+
raise TypeError
|
|
419
|
+
if not isinstance(is_ranked, bool):
|
|
420
|
+
raise TypeError
|
|
421
|
+
if not isinstance(new_commits_count, int) or isinstance(new_commits_count, bool):
|
|
422
|
+
raise TypeError
|
|
423
|
+
if new_commits_count < 0:
|
|
424
|
+
raise ValueError
|
|
425
|
+
return ApiLog(
|
|
426
|
+
commits=commits,
|
|
427
|
+
next_cursor=next_cursor,
|
|
428
|
+
is_ranked=is_ranked,
|
|
429
|
+
new_commits_count=new_commits_count,
|
|
430
|
+
suggestions=suggestions,
|
|
431
|
+
)
|
|
432
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
433
|
+
raise ApiError("The API returned an unexpected log response.") from error
|
|
434
|
+
|
|
435
|
+
@staticmethod
|
|
436
|
+
def _commit_from_payload(payload: dict) -> ApiCommit:
|
|
437
|
+
try:
|
|
438
|
+
author = payload["author"]
|
|
439
|
+
commit = ApiCommit(
|
|
440
|
+
id=payload["id"],
|
|
441
|
+
username=author["username"],
|
|
442
|
+
author_name=author.get("name"),
|
|
443
|
+
body=payload["body"],
|
|
444
|
+
created_at=payload["created_at"],
|
|
445
|
+
stars_count=payload["stars_count"],
|
|
446
|
+
replies_count=payload["replies_count"],
|
|
447
|
+
)
|
|
448
|
+
if not all(
|
|
449
|
+
isinstance(value, str)
|
|
450
|
+
for value in (
|
|
451
|
+
commit.id,
|
|
452
|
+
commit.username,
|
|
453
|
+
commit.body,
|
|
454
|
+
commit.created_at,
|
|
455
|
+
)
|
|
456
|
+
):
|
|
457
|
+
raise TypeError
|
|
458
|
+
if commit.author_name is not None and not isinstance(commit.author_name, str):
|
|
459
|
+
raise TypeError
|
|
460
|
+
if any(
|
|
461
|
+
not isinstance(value, int) or isinstance(value, bool) or value < 0
|
|
462
|
+
for value in (commit.stars_count, commit.replies_count)
|
|
463
|
+
):
|
|
464
|
+
raise TypeError
|
|
465
|
+
if not isinstance(author, dict):
|
|
466
|
+
raise TypeError
|
|
467
|
+
return commit
|
|
468
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
469
|
+
raise ApiError("The API returned a malformed commit in a log.") from error
|
|
470
|
+
|
|
471
|
+
@staticmethod
|
|
472
|
+
def _suggestion_from_payload(payload: dict) -> ApiUser:
|
|
473
|
+
try:
|
|
474
|
+
user = ApiUser(
|
|
475
|
+
username=payload["username"],
|
|
476
|
+
name=payload.get("name"),
|
|
477
|
+
avatar_url=payload.get("avatar_url"),
|
|
478
|
+
bio=payload.get("bio"),
|
|
479
|
+
)
|
|
480
|
+
if not isinstance(user.username, str) or any(
|
|
481
|
+
value is not None and not isinstance(value, str)
|
|
482
|
+
for value in (user.name, user.avatar_url, user.bio)
|
|
483
|
+
):
|
|
484
|
+
raise TypeError
|
|
485
|
+
return user
|
|
486
|
+
except (KeyError, TypeError) as error:
|
|
487
|
+
raise ApiError("The API returned a malformed log suggestion.") from error
|
|
488
|
+
|
|
489
|
+
def _request(self, method: str, path_and_query: str, body: bytes = b"") -> dict:
|
|
490
|
+
headers = signed_headers(method, path_and_query, body, self.key_path)
|
|
491
|
+
headers["Accept"] = "application/json"
|
|
492
|
+
headers["User-Agent"] = "dev-null-tty"
|
|
493
|
+
if body:
|
|
494
|
+
headers["Content-Type"] = "application/json"
|
|
495
|
+
request = urllib.request.Request(
|
|
496
|
+
f"{self.origin}{path_and_query}",
|
|
497
|
+
data=body if body else None,
|
|
498
|
+
headers=headers,
|
|
499
|
+
method=method,
|
|
500
|
+
)
|
|
501
|
+
try:
|
|
502
|
+
with self._opener.open(request, timeout=20) as response:
|
|
503
|
+
response_origin = urlsplit(response.geturl())
|
|
504
|
+
if response_origin.scheme != "https" or response_origin.netloc != urlsplit(self.origin).netloc:
|
|
505
|
+
raise ApiError("The API response came from an unexpected origin.")
|
|
506
|
+
payload = json.loads(response.read())
|
|
507
|
+
except urllib.error.HTTPError as error:
|
|
508
|
+
try:
|
|
509
|
+
response_payload = json.loads(error.read())
|
|
510
|
+
message = response_payload.get("message")
|
|
511
|
+
code = response_payload.get("code")
|
|
512
|
+
if not isinstance(message, str) or not message:
|
|
513
|
+
message = f"The API returned HTTP {error.code}."
|
|
514
|
+
except (json.JSONDecodeError, AttributeError):
|
|
515
|
+
redirect_location = error.headers.get("Location") if error.code in {301, 302, 303, 307, 308} else None
|
|
516
|
+
if redirect_location:
|
|
517
|
+
target = urlsplit(redirect_location)
|
|
518
|
+
if target.scheme == "https" and target.hostname in {"dev-null.sh", "www.dev-null.sh"}:
|
|
519
|
+
message = f"The signed API request was redirected to {target.netloc}{target.path}; it was not followed."
|
|
520
|
+
else:
|
|
521
|
+
message = "The signed API request was redirected to an unexpected destination; it was not followed."
|
|
522
|
+
else:
|
|
523
|
+
message = f"The API returned HTTP {error.code} without a JSON error response."
|
|
524
|
+
code = None
|
|
525
|
+
raise ApiError(message, code) from error
|
|
526
|
+
except urllib.error.URLError as error:
|
|
527
|
+
raise ApiError(f"Could not reach {self.origin}: {error.reason}") from error
|
|
528
|
+
except json.JSONDecodeError as error:
|
|
529
|
+
raise ApiError("The API returned invalid JSON.") from error
|
|
530
|
+
if not isinstance(payload, dict):
|
|
531
|
+
raise ApiError("The API returned an unexpected response.")
|
|
532
|
+
return payload
|
dev_null_tty/desktop.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Per-user Linux URI handler integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import uuid
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import api
|
|
15
|
+
from .api import ApiError, authorization_callback_state
|
|
16
|
+
|
|
17
|
+
DESKTOP_ID = "dev-null-tui-callback.desktop"
|
|
18
|
+
MIME_TYPE = "x-scheme-handler/devnull"
|
|
19
|
+
HANDLER_MARKER = "X-Dev-Null-TUI-Handler=true"
|
|
20
|
+
MAX_CALLBACK_LENGTH = 8192
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def callback_handoff_path(state: str) -> Path:
|
|
24
|
+
state_digest = hashlib.sha256(state.encode("ascii")).hexdigest()
|
|
25
|
+
return api.DEVICE_DIRECTORY / f"callback-{state_digest}.url"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def deliver_authorization_callback(callback_url: str) -> Path:
|
|
29
|
+
"""Queue a validated custom-scheme callback for the TUI process."""
|
|
30
|
+
|
|
31
|
+
if len(callback_url) > MAX_CALLBACK_LENGTH:
|
|
32
|
+
raise ApiError("The authorization callback URL is too long.")
|
|
33
|
+
state, _ = authorization_callback_state(callback_url)
|
|
34
|
+
api.ensure_device_directory()
|
|
35
|
+
destination = callback_handoff_path(state)
|
|
36
|
+
temporary_path = api.DEVICE_DIRECTORY / f".callback-{uuid.uuid4().hex}.tmp"
|
|
37
|
+
try:
|
|
38
|
+
descriptor = os.open(temporary_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
39
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as callback_file:
|
|
40
|
+
callback_file.write(callback_url)
|
|
41
|
+
callback_file.flush()
|
|
42
|
+
os.fsync(callback_file.fileno())
|
|
43
|
+
os.replace(temporary_path, destination)
|
|
44
|
+
except OSError as error:
|
|
45
|
+
temporary_path.unlink(missing_ok=True)
|
|
46
|
+
raise ApiError("Could not deliver the browser callback to the running TUI.") from error
|
|
47
|
+
return destination
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _quote_desktop_argument(value: str) -> str:
|
|
51
|
+
escaped = (
|
|
52
|
+
value.replace("\\", "\\\\")
|
|
53
|
+
.replace('"', '\\"')
|
|
54
|
+
.replace("`", "\\`")
|
|
55
|
+
.replace("$", "\\$")
|
|
56
|
+
.replace("%", "%%")
|
|
57
|
+
)
|
|
58
|
+
return f'"{escaped}"'
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _applications_directory() -> Path:
|
|
62
|
+
data_home = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local/share")).expanduser()
|
|
63
|
+
if not data_home.is_absolute():
|
|
64
|
+
raise ApiError("XDG_DATA_HOME must be an absolute path.")
|
|
65
|
+
return data_home / "applications"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def register_uri_handler() -> Path:
|
|
69
|
+
"""Install this app as the per-user handler for devnull:// links."""
|
|
70
|
+
|
|
71
|
+
if not sys.platform.startswith("linux"):
|
|
72
|
+
raise ApiError("Automatic URI-handler registration is supported on Linux only.")
|
|
73
|
+
xdg_mime = shutil.which("xdg-mime")
|
|
74
|
+
if xdg_mime is None:
|
|
75
|
+
raise ApiError("Install xdg-utils to register the devnull:// callback handler.")
|
|
76
|
+
update_database = shutil.which("update-desktop-database")
|
|
77
|
+
if update_database is None:
|
|
78
|
+
raise ApiError("Install desktop-file-utils to refresh the devnull:// handler registry.")
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
current = subprocess.run(
|
|
82
|
+
[xdg_mime, "query", "default", MIME_TYPE],
|
|
83
|
+
check=True,
|
|
84
|
+
capture_output=True,
|
|
85
|
+
text=True,
|
|
86
|
+
timeout=10,
|
|
87
|
+
).stdout.strip()
|
|
88
|
+
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
|
|
89
|
+
raise ApiError("Could not check the current devnull:// handler.") from error
|
|
90
|
+
if current and current != DESKTOP_ID:
|
|
91
|
+
raise ApiError(f"{MIME_TYPE} is already assigned to {current}; it was left unchanged.")
|
|
92
|
+
|
|
93
|
+
applications = _applications_directory()
|
|
94
|
+
desktop_file = applications / DESKTOP_ID
|
|
95
|
+
temporary_path: Path | None = None
|
|
96
|
+
try:
|
|
97
|
+
applications.mkdir(mode=0o755, parents=True, exist_ok=True)
|
|
98
|
+
if applications.is_symlink() or not applications.is_dir():
|
|
99
|
+
raise ApiError("The desktop applications path must be a real directory.")
|
|
100
|
+
if desktop_file.is_symlink():
|
|
101
|
+
raise ApiError("The callback desktop entry must not be a symbolic link.")
|
|
102
|
+
if desktop_file.exists() and HANDLER_MARKER not in desktop_file.read_text(encoding="utf-8"):
|
|
103
|
+
raise ApiError(f"Refusing to replace the unrelated desktop entry {desktop_file.name}.")
|
|
104
|
+
|
|
105
|
+
contents = "\n".join(
|
|
106
|
+
(
|
|
107
|
+
"[Desktop Entry]",
|
|
108
|
+
"Type=Application",
|
|
109
|
+
"Name=dev-null-tty callback handler",
|
|
110
|
+
"Terminal=false",
|
|
111
|
+
f"Exec={_quote_desktop_argument(sys.executable)} -m dev_null_tty --callback %u",
|
|
112
|
+
f"MimeType={MIME_TYPE};",
|
|
113
|
+
"Categories=Utility;",
|
|
114
|
+
HANDLER_MARKER,
|
|
115
|
+
"",
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
with tempfile.NamedTemporaryFile(
|
|
119
|
+
mode="w",
|
|
120
|
+
encoding="utf-8",
|
|
121
|
+
dir=applications,
|
|
122
|
+
prefix=".dev-null-tui-",
|
|
123
|
+
delete=False,
|
|
124
|
+
) as desktop_temp:
|
|
125
|
+
temporary_path = Path(desktop_temp.name)
|
|
126
|
+
desktop_temp.write(contents)
|
|
127
|
+
temporary_path.chmod(0o644)
|
|
128
|
+
os.replace(temporary_path, desktop_file)
|
|
129
|
+
except ApiError:
|
|
130
|
+
if temporary_path is not None:
|
|
131
|
+
temporary_path.unlink(missing_ok=True)
|
|
132
|
+
raise
|
|
133
|
+
except OSError as error:
|
|
134
|
+
if temporary_path is not None:
|
|
135
|
+
temporary_path.unlink(missing_ok=True)
|
|
136
|
+
raise ApiError("Could not install the devnull:// desktop handler.") from error
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
subprocess.run(
|
|
140
|
+
[update_database, str(applications)],
|
|
141
|
+
check=True,
|
|
142
|
+
capture_output=True,
|
|
143
|
+
text=True,
|
|
144
|
+
timeout=10,
|
|
145
|
+
)
|
|
146
|
+
subprocess.run(
|
|
147
|
+
[xdg_mime, "default", DESKTOP_ID, MIME_TYPE],
|
|
148
|
+
check=True,
|
|
149
|
+
capture_output=True,
|
|
150
|
+
text=True,
|
|
151
|
+
timeout=10,
|
|
152
|
+
)
|
|
153
|
+
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
|
|
154
|
+
raise ApiError(
|
|
155
|
+
"The handler file was installed, but xdg-mime could not set it as the default."
|
|
156
|
+
) from error
|
|
157
|
+
return desktop_file
|