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.
@@ -0,0 +1,5 @@
1
+ from . import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
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
@@ -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