susops 3.0.0rc3.dev1__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,312 @@
1
+ """SusOps services daemon — single long-running process that owns the
2
+ PAC server, status SSE endpoint, reconnect monitor, and bandwidth sampler.
3
+
4
+ Frontends (tray, TUI, CLI) talk to it over JSON-over-HTTP RPC.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import logging
10
+ import os
11
+ import signal
12
+ import sys
13
+ import threading
14
+ from pathlib import Path
15
+
16
+ WORKSPACE_DEFAULT = Path.home() / ".susops"
17
+ _PID_FILENAME = "susops-services.pid"
18
+ _PORT_FILENAME = "susops-services.port"
19
+
20
+
21
+ def _pid_path(workspace: Path) -> Path:
22
+ return workspace / "pids" / _PID_FILENAME
23
+
24
+
25
+ def _port_path(workspace: Path) -> Path:
26
+ return workspace / "pids" / _PORT_FILENAME
27
+
28
+
29
+ def _write_pid_file(workspace: Path) -> None:
30
+ p = _pid_path(workspace)
31
+ p.parent.mkdir(parents=True, exist_ok=True)
32
+ p.write_text(str(os.getpid()))
33
+
34
+
35
+ def _claim_pid_file(workspace: Path) -> bool:
36
+ """Atomically create the PID file with this process's PID.
37
+
38
+ Uses O_CREAT | O_EXCL so two daemons racing each other can't both
39
+ succeed — at most one will create the file, the other gets
40
+ FileExistsError. Returns True if we claimed it, False otherwise.
41
+ """
42
+ p = _pid_path(workspace)
43
+ p.parent.mkdir(parents=True, exist_ok=True)
44
+ try:
45
+ fd = os.open(str(p), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
46
+ except FileExistsError:
47
+ return False
48
+ try:
49
+ os.write(fd, str(os.getpid()).encode())
50
+ finally:
51
+ os.close(fd)
52
+ return True
53
+
54
+
55
+ def _remove_pid_file(workspace: Path) -> None:
56
+ try:
57
+ _pid_path(workspace).unlink(missing_ok=True)
58
+ except Exception:
59
+ pass
60
+
61
+
62
+ def _remove_port_file(workspace: Path) -> None:
63
+ try:
64
+ _port_path(workspace).unlink(missing_ok=True)
65
+ except Exception:
66
+ pass
67
+
68
+
69
+ _EXIT_ANOTHER_DAEMON_ALIVE = 2
70
+ _EXIT_PAC_PORT_SQUATTED = 3
71
+
72
+
73
+ def _preflight(workspace: Path, log: "logging.Logger") -> None:
74
+ """Refuse to start if another daemon is alive or PAC port is squatted.
75
+
76
+ Atomic via O_EXCL on the PID file — two daemons racing each other
77
+ can't both pass this check. Converts the silent-failure modes that
78
+ bit us during development (orphan accumulation, double-spawn races)
79
+ into loud, actionable exits.
80
+
81
+ On success the PID file is written with our pid (which means the
82
+ daemon's shutdown finally block MUST clean it up).
83
+ """
84
+ # Try to claim the PID file atomically.
85
+ if _claim_pid_file(workspace):
86
+ # Won the race uncontested.
87
+ pass
88
+ else:
89
+ # File exists. Is the holder alive?
90
+ pid_file = _pid_path(workspace)
91
+ try:
92
+ existing_pid = int(pid_file.read_text().strip())
93
+ os.kill(existing_pid, 0) # liveness probe
94
+ except (OSError, ValueError):
95
+ # Stale PID file. Remove + retry the atomic claim once.
96
+ pid_file.unlink(missing_ok=True)
97
+ _port_path(workspace).unlink(missing_ok=True)
98
+ if not _claim_pid_file(workspace):
99
+ # Some other daemon claimed it between our cleanup and retry.
100
+ log.error(
101
+ "another susops-services daemon raced us to start. "
102
+ "Try again — or run "
103
+ "`pgrep -lf susops-services` to inspect."
104
+ )
105
+ sys.exit(_EXIT_ANOTHER_DAEMON_ALIVE)
106
+ else:
107
+ log.error(
108
+ "another susops-services daemon is already running (pid=%d). "
109
+ "Stop it first with `kill %d` (or `kill -9 %d` if it's wedged) "
110
+ "before starting a new one.",
111
+ existing_pid, existing_pid, existing_pid,
112
+ )
113
+ sys.exit(_EXIT_ANOTHER_DAEMON_ALIVE)
114
+
115
+ # 2. Is the configured PAC port held by an untracked process?
116
+ try:
117
+ from susops.core.config import load_config
118
+ cfg = load_config(workspace)
119
+ pac_port = cfg.pac_server_port
120
+ except Exception:
121
+ pac_port = 0
122
+ if not pac_port:
123
+ return # 0 means auto-allocate, no port to preflight
124
+
125
+ import socket as _socket
126
+ probe = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
127
+ probe.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
128
+ try:
129
+ probe.bind(("127.0.0.1", pac_port))
130
+ except OSError as exc:
131
+ log.error(
132
+ "PAC port %d is bound by another process (%s). "
133
+ "Likely an orphan susops-services or susops.core.pac from a "
134
+ "previous run. Recover with:\n"
135
+ " pkill -9 -f 'susops-services|susops.core.services_daemon|susops.core.pac'\n"
136
+ " rm -f %s/pids/susops-services.{pid,port}\n"
137
+ "Then start this daemon again.",
138
+ pac_port, exc, workspace,
139
+ )
140
+ # We claimed the PID file above; clean it up so the next attempt
141
+ # isn't blocked by us.
142
+ _remove_pid_file(workspace)
143
+ sys.exit(_EXIT_PAC_PORT_SQUATTED)
144
+ finally:
145
+ probe.close()
146
+
147
+
148
+ def main() -> int:
149
+ parser = argparse.ArgumentParser(description="SusOps services daemon")
150
+ parser.add_argument("--workspace", default=str(WORKSPACE_DEFAULT))
151
+ parser.add_argument("--port", type=int, default=0,
152
+ help="RPC port; 0 = auto-allocate")
153
+ args = parser.parse_args()
154
+ workspace = Path(args.workspace)
155
+
156
+ logging.basicConfig(level=logging.INFO,
157
+ format="%(asctime)s [services] %(message)s")
158
+ log = logging.getLogger("susops.services")
159
+
160
+ # Install signal handlers BEFORE preflight: SIGTERM arriving in the
161
+ # window between claiming the PID file and entering the try block
162
+ # otherwise triggers Python's default handler, bypassing cleanup.
163
+ stop_event = threading.Event()
164
+
165
+ def _shutdown(signum, _frame) -> None:
166
+ log.info("Received signal %d, shutting down", signum)
167
+ stop_event.set()
168
+
169
+ signal.signal(signal.SIGTERM, _shutdown)
170
+ signal.signal(signal.SIGINT, _shutdown)
171
+
172
+ _preflight(workspace, log)
173
+
174
+ mgr = None
175
+ try:
176
+ import time as _time
177
+ from susops.core.rpc_server import serve
178
+ from susops.facade import SusOpsManager
179
+
180
+ mgr = SusOpsManager(workspace=workspace, _enable_background_threads=True)
181
+ # CLI `--port` wins. Fall back to the configured `rpc_server_port`,
182
+ # then 0 (auto-allocate). When we auto-allocate, persist the bound
183
+ # port back to config so subsequent spawns reuse it.
184
+ requested_port = args.port or mgr.config.rpc_server_port or 0
185
+ runner, actual_port = serve(mgr, port=requested_port)
186
+ if requested_port == 0 and actual_port != mgr.config.rpc_server_port:
187
+ mgr.config = mgr.config.model_copy(
188
+ update={"rpc_server_port": actual_port}
189
+ )
190
+ mgr._save()
191
+ _port_path(workspace).write_text(str(actual_port))
192
+ log.info("RPC listening on 127.0.0.1:%d", actual_port)
193
+
194
+ # Start the SSE status server eagerly so frontends can connect
195
+ # immediately on daemon spawn, and so the daemon-startup log can
196
+ # report the SSE port too. Previously this was lazy (started on the
197
+ # first tunnel `start()` call), which left SSE listeners spinning in
198
+ # backoff for several seconds after a fresh daemon spawn.
199
+ sse_port = mgr.ensure_sse_status_server()
200
+
201
+ # Surface daemon-startup details in the in-memory log buffer too so
202
+ # they show up in the TUI Logs tab and the tray Logs window — the
203
+ # `log.info` calls only land on the daemon process's stderr.
204
+ try:
205
+ sse_str = f"SSE port {sse_port}" if sse_port else "SSE port unavailable"
206
+ mgr._log(
207
+ f"Daemon started — RPC port {actual_port}, {sse_str} (PID {os.getpid()})"
208
+ )
209
+ mgr._log(f"Workspace: {workspace}")
210
+ except Exception:
211
+ pass
212
+
213
+ # Idle-shutdown. Exit when the last SSE client disconnects AND there's
214
+ # no in-flight work (no SSH masters, no shares, no PAC, no watched
215
+ # connections). Startup gets a small grace so a frontend that calls
216
+ # ensure_daemon_running() has time to make its first SSE connection
217
+ # before the periodic check fires.
218
+ _IDLE_STARTUP_GRACE_S = 3.0
219
+ _IDLE_CHECK_INTERVAL_S = 5.0
220
+ startup_time = _time.monotonic()
221
+
222
+ def _should_exit() -> bool:
223
+ if _time.monotonic() - startup_time < _IDLE_STARTUP_GRACE_S:
224
+ return False
225
+ if mgr is None:
226
+ return False
227
+ if mgr._status_server.client_count() > 0:
228
+ return False
229
+ return mgr.is_idle()
230
+
231
+ def _on_clients_changed(count: int) -> None:
232
+ # Fast path — react immediately when the last SSE client drops.
233
+ # The periodic check below handles the "no SSE was ever opened"
234
+ # case (e.g. `susops ps` fires one RPC and exits).
235
+ if count > 0:
236
+ return
237
+ if _should_exit():
238
+ msg = "Last client disconnected and no work pending — shutting down"
239
+ log.info(msg)
240
+ try:
241
+ mgr._log(msg)
242
+ except Exception:
243
+ pass
244
+ stop_event.set()
245
+
246
+ mgr._status_server.on_clients_changed = _on_clients_changed
247
+ # Surface SSE connect/disconnect in the in-memory log buffer so the
248
+ # TUI Logs tab and tray Logs window can show "tui connected" etc.
249
+ mgr._status_server.on_log = mgr._log
250
+
251
+ def _idle_watcher() -> None:
252
+ while not stop_event.is_set():
253
+ if stop_event.wait(_IDLE_CHECK_INTERVAL_S):
254
+ return
255
+ if _should_exit():
256
+ msg = (f"Idle for {_IDLE_CHECK_INTERVAL_S:.0f}s with "
257
+ f"no clients — shutting down")
258
+ log.info(msg)
259
+ try:
260
+ mgr._log(msg)
261
+ except Exception:
262
+ pass
263
+ stop_event.set()
264
+ return
265
+
266
+ threading.Thread(
267
+ target=_idle_watcher, daemon=True, name="susops-idle-watcher",
268
+ ).start()
269
+
270
+ log.info("Daemon started, pid=%d, workspace=%s", os.getpid(), workspace)
271
+ stop_event.wait()
272
+ # If we land here it's because the idle-watcher or a signal asked us
273
+ # to exit — surface it in the in-memory log so frontends can see
274
+ # *why* the daemon went away rather than just noticing it's gone.
275
+ if mgr is not None:
276
+ try:
277
+ mgr._log("Daemon shutting down")
278
+ except Exception:
279
+ pass
280
+ finally:
281
+ # Remove PID + port files FIRST so subsequent ensure_daemon_running()
282
+ # calls don't think we're still alive while we're shutting down.
283
+ # Anything that hangs below can't strand us with a stale PID file.
284
+ _remove_pid_file(workspace)
285
+ _remove_port_file(workspace)
286
+
287
+ # Stop the manager (kills SSH masters, stops PAC/status threads).
288
+ # Wrapped in a watchdog timer — if a child won't die we still exit.
289
+ def _watchdog():
290
+ import time as _t
291
+ _t.sleep(5.0)
292
+ log.error("Shutdown watchdog tripped, force-exiting")
293
+ os._exit(1)
294
+
295
+ threading.Thread(target=_watchdog, daemon=True, name="susops-services-watchdog").start()
296
+
297
+ if mgr is not None:
298
+ try:
299
+ mgr.stop_quick()
300
+ except Exception:
301
+ log.exception("Error during manager stop")
302
+ # NOTE: we intentionally do NOT call `asyncio.run(runner.cleanup())`.
303
+ # The aiohttp runner lives on a separate event loop (see serve() in
304
+ # rpc_server.py) running on a daemon thread. Spawning a fresh loop
305
+ # here to await an object from a different loop deadlocks. Process
306
+ # exit closes the listening sockets cleanly anyway.
307
+ log.info("Daemon stopped")
308
+ return 0
309
+
310
+
311
+ if __name__ == "__main__":
312
+ sys.exit(main())
susops/core/share.py ADDED
@@ -0,0 +1,323 @@
1
+ """Encrypted file sharing server and client for SusOps.
2
+
3
+ Replaces the bash implementation that used nc + openssl + gzip.
4
+ Uses aiohttp for async HTTP serving + cryptography library for AES-256-CTR encryption.
5
+
6
+ Protocol (same as original):
7
+ - HTTP Basic auth with credentials ":password" (empty username)
8
+ - File is gzip-compressed then AES-256-CTR encrypted with PBKDF2 key derivation
9
+ - Original filename is AES-encrypted and base64-encoded in Content-Disposition
10
+ - Served as application/octet-stream
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import base64
16
+ import gzip
17
+ import os
18
+ import secrets
19
+ import string
20
+ import threading
21
+ from pathlib import Path
22
+
23
+ from susops.core.types import ShareInfo
24
+
25
+ __all__ = ["ShareServer", "fetch_file", "generate_password", "ShareInfo"]
26
+
27
+
28
+ def _derive_key(password: str, salt: bytes) -> bytes:
29
+ """Derive a 32-byte AES key from a password using PBKDF2-HMAC-SHA256."""
30
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
31
+ from cryptography.hazmat.primitives import hashes
32
+
33
+ kdf = PBKDF2HMAC(
34
+ algorithm=hashes.SHA256(),
35
+ length=32,
36
+ salt=salt,
37
+ iterations=600_000,
38
+ )
39
+ return kdf.derive(password.encode())
40
+
41
+
42
+ def _encrypt(data: bytes, password: str) -> bytes:
43
+ """Encrypt bytes using AES-256-CTR.
44
+
45
+ Format: magic(8) + salt(8) + iv(16) + ciphertext
46
+ """
47
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
48
+
49
+ salt = os.urandom(8)
50
+ key = _derive_key(password, salt)
51
+ iv = os.urandom(16)
52
+
53
+ cipher = Cipher(algorithms.AES(key), modes.CTR(iv))
54
+ encryptor = cipher.encryptor()
55
+ ciphertext = encryptor.update(data) + encryptor.finalize()
56
+
57
+ return b"Salted__" + salt + iv + ciphertext
58
+
59
+
60
+ def _decrypt(data: bytes, password: str) -> bytes:
61
+ """Decrypt bytes produced by _encrypt()."""
62
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
63
+
64
+ if not data.startswith(b"Salted__"):
65
+ raise ValueError("Invalid encrypted data: missing 'Salted__' header")
66
+ salt = data[8:16]
67
+ iv = data[16:32]
68
+ ciphertext = data[32:]
69
+
70
+ key = _derive_key(password, salt)
71
+ cipher = Cipher(algorithms.AES(key), modes.CTR(iv))
72
+ decryptor = cipher.decryptor()
73
+ return decryptor.update(ciphertext) + decryptor.finalize()
74
+
75
+
76
+ def _compress_and_encrypt(file_path: Path, password: str) -> bytes:
77
+ """Gzip-compress then AES-256-CTR encrypt a file."""
78
+ raw = file_path.read_bytes()
79
+ compressed = gzip.compress(raw)
80
+ return _encrypt(compressed, password)
81
+
82
+
83
+ def _decrypt_and_decompress(data: bytes, password: str) -> bytes:
84
+ """AES-256-CTR decrypt then gunzip."""
85
+ compressed = _decrypt(data, password)
86
+ return gzip.decompress(compressed)
87
+
88
+
89
+ def _encrypt_filename(filename: str, password: str) -> str:
90
+ """Encrypt a filename string and return base64-encoded ciphertext."""
91
+ encrypted = _encrypt(filename.encode(), password)
92
+ return base64.urlsafe_b64encode(encrypted).decode()
93
+
94
+
95
+ def _decrypt_filename(encrypted_b64: str, password: str) -> str:
96
+ """Decrypt a base64-encoded encrypted filename."""
97
+ encrypted = base64.urlsafe_b64decode(encrypted_b64.encode())
98
+ return _decrypt(encrypted, password).decode()
99
+
100
+
101
+ def generate_password(length: int = 24) -> str:
102
+ """Generate a secure random password (alphanumeric)."""
103
+ alphabet = string.ascii_letters + string.digits
104
+ return "".join(secrets.choice(alphabet) for _ in range(length))
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Shared event loop — all ShareServer instances and the StatusServer reuse one
109
+ # daemon thread + asyncio loop to avoid creating multiple threads.
110
+ # ---------------------------------------------------------------------------
111
+
112
+ _loop: asyncio.AbstractEventLoop | None = None
113
+ _loop_lock = threading.Lock()
114
+
115
+
116
+ def _get_loop() -> asyncio.AbstractEventLoop:
117
+ """Return (and lazily initialise) the shared daemon event loop."""
118
+ global _loop
119
+ with _loop_lock:
120
+ if _loop is None or _loop.is_closed():
121
+ _loop = asyncio.new_event_loop()
122
+ t = threading.Thread(
123
+ target=_loop.run_forever,
124
+ name="susops-async-loop",
125
+ daemon=True,
126
+ )
127
+ t.start()
128
+ return _loop
129
+
130
+
131
+ class ShareServer:
132
+ """Async HTTP file share server with AES-256-CTR encryption.
133
+
134
+ The file is encrypted in memory and served via HTTP Basic auth using aiohttp.
135
+ Multiple ShareServer instances share one background event loop thread.
136
+ """
137
+
138
+ def __init__(self) -> None:
139
+ self._runner = None
140
+ self._port: int = 0
141
+ self._encrypted_data: bytes = b""
142
+ self._encrypted_filename: str = ""
143
+ self._password: str = ""
144
+ self._access_count: int = 0
145
+ self._failed_count: int = 0
146
+
147
+ def start(
148
+ self,
149
+ file_path: Path,
150
+ password: str,
151
+ port: int = 0,
152
+ workspace: Path | None = None,
153
+ ) -> ShareInfo:
154
+ """Encrypt and start serving the file asynchronously.
155
+
156
+ Returns ShareInfo with the URL and credentials.
157
+ """
158
+ if self._runner is not None:
159
+ raise RuntimeError("Share server is already running")
160
+
161
+ self._encrypted_data = _compress_and_encrypt(file_path, password)
162
+ self._encrypted_filename = _encrypt_filename(file_path.name, password)
163
+ self._password = password
164
+
165
+ loop = _get_loop()
166
+ future = asyncio.run_coroutine_threadsafe(
167
+ self._start_async(port), loop
168
+ )
169
+ self._port = future.result(timeout=10)
170
+
171
+ return ShareInfo(
172
+ file_path=str(file_path),
173
+ port=self._port,
174
+ password=password,
175
+ url=f"http://localhost:{self._port}",
176
+ )
177
+
178
+ async def _start_async(self, port: int) -> int:
179
+ from aiohttp import web
180
+
181
+ async def handle(request: web.Request) -> web.Response:
182
+ auth = request.headers.get("Authorization", "")
183
+ if not auth.startswith("Basic "):
184
+ self._failed_count += 1
185
+ return web.Response(
186
+ status=401,
187
+ headers={"WWW-Authenticate": 'Basic realm="susops share"'},
188
+ )
189
+ try:
190
+ decoded = base64.b64decode(auth[6:]).decode()
191
+ _, _, pw = decoded.partition(":")
192
+ except Exception:
193
+ self._failed_count += 1
194
+ return web.Response(status=401)
195
+ if pw != self._password:
196
+ self._failed_count += 1
197
+ return web.Response(
198
+ status=401,
199
+ headers={"WWW-Authenticate": 'Basic realm="susops share"'},
200
+ )
201
+
202
+ self._access_count += 1
203
+ return web.Response(
204
+ body=self._encrypted_data,
205
+ content_type="application/octet-stream",
206
+ headers={
207
+ "Content-Disposition": f'attachment; filename="{self._encrypted_filename}"',
208
+ "Connection": "close",
209
+ },
210
+ )
211
+
212
+ app = web.Application()
213
+ app.router.add_get("/", handle)
214
+
215
+ runner = web.AppRunner(app)
216
+ await runner.setup()
217
+ site = web.TCPSite(runner, "127.0.0.1", port)
218
+ await site.start()
219
+ self._runner = runner
220
+ # Resolve the actual bound port (important when port=0)
221
+ return site._server.sockets[0].getsockname()[1] # type: ignore[union-attr]
222
+
223
+ def stop(self) -> None:
224
+ """Stop the share server."""
225
+ if self._runner is not None:
226
+ loop = _get_loop()
227
+ future = asyncio.run_coroutine_threadsafe(
228
+ self._runner.cleanup(), loop
229
+ )
230
+ try:
231
+ future.result(timeout=5)
232
+ except Exception:
233
+ pass
234
+ self._runner = None
235
+ self._port = 0
236
+
237
+ def is_running(self) -> bool:
238
+ return self._runner is not None
239
+
240
+ def get_port(self) -> int:
241
+ return self._port
242
+
243
+ @property
244
+ def access_count(self) -> int:
245
+ return self._access_count
246
+
247
+ @property
248
+ def failed_count(self) -> int:
249
+ return self._failed_count
250
+
251
+
252
+ def fetch_file(
253
+ host: str,
254
+ port: int,
255
+ password: str,
256
+ outfile: Path | None = None,
257
+ ) -> Path:
258
+ """Download and decrypt a file from a ShareServer.
259
+
260
+ Args:
261
+ host: Hostname or IP (e.g. "localhost").
262
+ port: Port the share server is listening on.
263
+ password: Decryption password.
264
+ outfile: Where to save the file. Defaults to ~/Downloads/<original_name>.
265
+
266
+ Returns:
267
+ Path to the saved decrypted file.
268
+ """
269
+ loop = _get_loop()
270
+ future = asyncio.run_coroutine_threadsafe(
271
+ _fetch_async(host, port, password, outfile), loop
272
+ )
273
+ return future.result(timeout=60)
274
+
275
+
276
+ async def _fetch_async(
277
+ host: str,
278
+ port: int,
279
+ password: str,
280
+ outfile: Path | None,
281
+ ) -> Path:
282
+ import aiohttp
283
+
284
+ url = f"http://{host}:{port}"
285
+ credentials = base64.b64encode(f":{password}".encode()).decode()
286
+
287
+ async with aiohttp.ClientSession() as session:
288
+ async with session.get(
289
+ url,
290
+ headers={"Authorization": f"Basic {credentials}"},
291
+ timeout=aiohttp.ClientTimeout(total=30),
292
+ ) as resp:
293
+ if resp.status != 200:
294
+ raise RuntimeError(f"Share server returned HTTP {resp.status}")
295
+
296
+ content_disp = resp.headers.get("Content-Disposition", "")
297
+ encrypted_filename = ""
298
+ for part in content_disp.split(";"):
299
+ part = part.strip()
300
+ if part.startswith("filename="):
301
+ encrypted_filename = part[9:].strip('"')
302
+ break
303
+
304
+ encrypted_data = await resp.read()
305
+
306
+ decrypted = _decrypt_and_decompress(encrypted_data, password)
307
+
308
+ if outfile is None:
309
+ if encrypted_filename:
310
+ try:
311
+ original_name = _decrypt_filename(encrypted_filename, password)
312
+ except Exception:
313
+ original_name = f"download_{secrets.token_hex(4)}"
314
+ else:
315
+ original_name = f"download_{secrets.token_hex(4)}"
316
+
317
+ downloads = Path.home() / "Downloads"
318
+ downloads.mkdir(exist_ok=True)
319
+ outfile = downloads / original_name
320
+
321
+ outfile.parent.mkdir(parents=True, exist_ok=True)
322
+ outfile.write_bytes(decrypted)
323
+ return outfile