ZKAC/demo/file_share_tui.py
everbarry d5ae07973a polish and self-contain file-share demo UI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 18:39:39 +02:00

1004 lines
39 KiB
Python

#!/usr/bin/env python3
"""
ZKAC file-share demo Textual TUI.
Run the headless server in another terminal::
uv run python demo/file_share_server.py --port 9879
Then start the Textual app::
uv run python demo/file_share_tui.py
"""
from __future__ import annotations
import base64
import json
import os
import shutil
import subprocess
import sys
import time
import traceback
from pathlib import Path
import zkac
from textual import events
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Footer, Header, Input, Label, Static, TextArea
import file_share_client as fsc
import file_share_credentials as fscred
import zkac_cli_adapter as cli
class PromptScreen(ModalScreen[str | None]):
AUTO_FOCUS = "#prompt_input"
BINDINGS = [
("escape", "cancel", "Cancel"),
("ctrl+q", "quit_app", "Quit"),
("q", "quit_app", "Quit"),
("ctrl+c", "quit_app", "Quit"),
]
def __init__(self, label: str, default: str = "") -> None:
super().__init__()
self._label = label
self._default = default
def compose(self) -> ComposeResult:
with Vertical(id="prompt_dialog"):
yield Label(self._label, id="prompt_label")
yield Input(value=self._default, id="prompt_input")
with Horizontal(id="prompt_buttons"):
yield Button("OK", id="ok", variant="primary")
yield Button("Cancel", id="cancel")
def on_mount(self) -> None:
# Defer focus until after layout settles so typing works immediately.
self.call_after_refresh(self._focus_prompt_input)
self.set_timer(0.05, self._focus_prompt_input)
def _focus_prompt_input(self) -> None:
self.query_one("#prompt_input", Input).focus()
def _move_button_focus(self, step: int) -> bool:
focused = self.focused
if not isinstance(focused, Button):
return False
buttons = list(self.query("#prompt_buttons Button"))
if len(buttons) < 2 or focused not in buttons:
return False
idx = buttons.index(focused)
buttons[(idx + step) % len(buttons)].focus()
return True
def on_key(self, event) -> None: # noqa: ANN001
if event.key == "left" and self._move_button_focus(-1):
event.stop()
return
if event.key == "right" and self._move_button_focus(1):
event.stop()
return
if event.key == "down" and isinstance(self.focused, Input):
buttons = list(self.query("#prompt_buttons Button"))
if buttons:
buttons[0].focus()
event.stop()
return
if event.key == "up" and isinstance(self.focused, Button):
self.query_one("#prompt_input", Input).focus()
event.stop()
def action_cancel(self) -> None:
self.dismiss(None)
def action_quit_app(self) -> None:
self.app.exit()
def on_input_submitted(self, event: Input.Submitted) -> None:
self.dismiss(event.value.strip())
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "ok":
value = self.query_one("#prompt_input", Input).value.strip()
self.dismiss(value)
return
self.dismiss(None)
class ConfirmScreen(ModalScreen[bool]):
AUTO_FOCUS = "#yes"
BINDINGS = [
("escape", "cancel", "Cancel"),
("ctrl+q", "quit_app", "Quit"),
("q", "quit_app", "Quit"),
("ctrl+c", "quit_app", "Quit"),
]
def __init__(self, label: str) -> None:
super().__init__()
self._label = label
def compose(self) -> ComposeResult:
with Vertical(id="prompt_dialog"):
yield Label(self._label, id="prompt_label")
with Horizontal(id="prompt_buttons"):
yield Button("Yes", id="yes", variant="primary")
yield Button("No", id="no")
def on_mount(self) -> None:
self.query_one("#yes", Button).focus()
def _move_button_focus(self, step: int) -> bool:
focused = self.focused
if not isinstance(focused, Button):
return False
buttons = list(self.query("#prompt_buttons Button"))
if len(buttons) < 2 or focused not in buttons:
return False
idx = buttons.index(focused)
buttons[(idx + step) % len(buttons)].focus()
return True
def on_key(self, event) -> None: # noqa: ANN001
if event.key == "left" and self._move_button_focus(-1):
event.stop()
return
if event.key == "right" and self._move_button_focus(1):
event.stop()
def on_button_pressed(self, event: Button.Pressed) -> None:
self.dismiss(event.button.id == "yes")
def action_cancel(self) -> None:
self.dismiss(False)
def action_quit_app(self) -> None:
self.app.exit()
class FileShareApp(App[None]):
TITLE = "ZKAC File Share"
BINDINGS = [
("ctrl+q", "quit_app", "Quit"),
("q", "quit_app", "Quit"),
("ctrl+c", "quit_app", "Quit"),
("c", "copy_contact", "Copy Contact"),
]
CSS = """
#root {
padding: 1 2;
}
#status {
border: round $accent;
padding: 0 1;
height: 4;
margin-bottom: 1;
}
#actions {
layout: grid;
grid-size: 8 1;
grid-gutter: 1 0;
height: auto;
margin-bottom: 1;
}
#actions Button {
width: 1fr;
height: 3;
text-align: center;
content-align: center middle;
}
#log {
border: round $panel;
}
#prompt_dialog {
width: 70;
height: auto;
padding: 1 2;
border: round $accent;
background: $surface;
}
#prompt_label {
margin-bottom: 1;
}
#prompt_buttons {
margin-top: 1;
height: auto;
}
"""
def __init__(self) -> None:
super().__init__()
self.userid: str | None = None
self.identity: cli.Identity | None = None
self.server: str | None = None
self.server_pk_hex: str | None = None
self.current_bucket_id: str | None = None
self.listener: cli.P2PListener | None = None
self._action_running = False
self._log_lines: list[str] = []
self._last_clipboard_error = ""
self._last_clipboard_backend = ""
self._last_contact_bundle = ""
self._server_key_maybe_stale_for: str | None = None
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Vertical(id="root"):
yield Static(id="status")
with Vertical(id="actions"):
yield Button("Login", id="login", variant="primary")
yield Button("Connect", id="connect")
yield Button("Select Bucket", id="bucket")
yield Button("Permissions", id="permissions")
yield Button("Share Permissions", id="share")
yield Button("Listen", id="listen")
yield Button("Inbox", id="inbox")
yield Button("Quit", id="quit", variant="error")
yield TextArea("", id="log", read_only=True)
yield Footer()
def on_mount(self) -> None:
self._refresh_status()
self.write_log("Textual file-share UI ready.")
self.write_log(f"Demo ZKAC_HOME: {cli.zkac_home()}")
self.write_log("Use the action buttons to run flows.")
self.set_interval(0.8, self._poll_listener)
self._update_actions_layout()
self.query_one("#login", Button).focus()
def on_resize(self, _event: events.Resize) -> None:
self._update_actions_layout()
def on_unmount(self) -> None:
if self.listener:
self.listener.stop()
def write_log(self, msg: str) -> None:
self._log_lines.extend(msg.splitlines() or [""])
log_view = self.query_one("#log", TextArea)
log_view.load_text("\n".join(self._log_lines))
if not isinstance(self.focused, TextArea):
log_view.scroll_end(animate=False)
def _refresh_status(self) -> None:
listener_state = "stopped"
if self.listener and self.listener.is_running():
listener_state = f"listening on {self.listener.address}"
elif self.listener:
listener_state = "stopped (last)"
status = (
f"user: {self.userid or '-'}\n"
f"server: {self.server or '-'}\n"
f"bucket: {self.current_bucket_id or '-'}\n"
f"p2p-listen: {listener_state}"
)
self.query_one("#status", Static).update(status)
def _update_actions_layout(self) -> None:
actions = self.query_one("#actions")
if self.size.width >= 110:
actions.styles.grid_size_columns = 8
actions.styles.grid_size_rows = 1
else:
actions.styles.grid_size_columns = 4
actions.styles.grid_size_rows = 2
def _move_action_focus(self, step: int) -> bool:
focused = self.focused
if not isinstance(focused, Button):
return False
buttons = list(self.query("#actions Button"))
if not buttons or focused not in buttons:
return False
idx = buttons.index(focused)
buttons[(idx + step) % len(buttons)].focus()
return True
def on_key(self, event) -> None: # noqa: ANN001
# Never intercept keys while a modal prompt/confirm screen is active.
if self.screen is not self:
return
if event.key == "left" and self._move_action_focus(-1):
event.stop()
return
if event.key == "right" and self._move_action_focus(1):
event.stop()
return
if event.key == "down" and isinstance(self.focused, Button):
self.query_one("#log", TextArea).focus()
event.stop()
return
if event.key == "up" and isinstance(self.focused, TextArea):
self.query_one("#login", Button).focus()
event.stop()
def action_quit_app(self) -> None:
self.exit()
async def ask(self, label: str, default: str = "") -> str | None:
return await self.push_screen_wait(PromptScreen(label, default))
async def confirm(self, label: str) -> bool:
return await self.push_screen_wait(ConfirmScreen(label))
def _require_user(self) -> cli.Identity:
if not self.userid or not self.identity:
raise RuntimeError("no current user; click Login first")
return self.identity
def _require_server(self) -> tuple[str, str]:
if not self.server or not self.server_pk_hex:
raise RuntimeError("no current server; click Connect first")
return self.server, self.server_pk_hex
def _open_session(self, registry_id_hex: str, role_name: str) -> fsc.FileShareSession:
ident = self._require_user()
server, server_pk_hex = self._require_server()
if role_name == "__admin__":
credential = cli.load_admin_credential(self.userid or "", registry_id_hex)
role_id = zkac.admin_role_id()
else:
credential = cli.load_credential(self.userid or "", registry_id_hex, role_name)
role_id = zkac.role_id(role_name)
return fsc.open_session(
server,
server_pk_hex=server_pk_hex,
user_transport_secret=bytes.fromhex(ident.transport_secret_hex),
registry_id_hex=registry_id_hex,
role_id=role_id,
credential=credential,
user_issuance_pk_hex=ident.issuance_pk_hex,
)
async def _choose_index(self, title: str, options: list[str], default: int = 1) -> int:
if not options:
raise RuntimeError(f"no options for {title}")
self.write_log(f"[{title}]")
for i, opt in enumerate(options, 1):
self.write_log(f" {i}) {opt}")
raw = await self.ask(f"{title}: pick number", str(default))
if raw is None:
raise RuntimeError("cancelled")
if not raw.isdigit():
raise RuntimeError("selection must be a number")
idx = int(raw) - 1
if not 0 <= idx < len(options):
raise RuntimeError("invalid selection")
return idx
async def _pick_owned_registry(self) -> str:
regs = cli.registry_list(self.userid or "")
if not regs:
raise RuntimeError("you do not own any registries; create one in Registry")
if self.server:
regs = [r for r in regs if r["server"] == self.server] or regs
opts = [f"{r['registry_id'][:16]}... @ {r['server']} roles={r['roles']}" for r in regs]
idx = await self._choose_index("Owned registries", opts)
return regs[idx]["registry_id"]
def _poll_listener(self) -> None:
if not self.listener or self.listener.is_running():
return
result = self.listener.parse_received()
if result is not None:
self.write_log(
f"[listener] received credential: registry={result['registry_id'][:16]}... "
f"role={result['role']}"
)
self.listener = None
self._refresh_status()
def _log_exception(self, exc: BaseException) -> None:
self.write_log(f"error: {exc}")
msg = str(exc).lower()
if self.server and any(s in msg for s in ("auth failed", "handshake", "public key", "server key")):
self._server_key_maybe_stale_for = self.server
self.write_log(
"[hint] server key may be stale. Use Connect to update the pinned transport key."
)
if "--debug" in sys.argv:
self.write_log(traceback.format_exc())
def on_button_pressed(self, event: Button.Pressed) -> None:
action = event.button.id or ""
if action == "quit":
self.exit()
return
if self._action_running:
self.write_log("Another action is still running; wait or press q to quit.")
return
self._action_running = True
self.run_worker(self._run_action(action), exclusive=True, thread=False)
async def _run_action(self, action: str) -> None:
try:
if action == "login":
await self.menu_login()
elif action == "connect":
await self.menu_connect()
elif action == "bucket":
await self.menu_buckets()
elif action == "permissions":
await self._bucket_set_masks()
elif action == "share":
await self.menu_share()
elif action == "listen":
await self.menu_listen()
elif action == "inbox":
await self.menu_inbox()
self._refresh_status()
except Exception as exc: # noqa: BLE001
self._log_exception(exc)
finally:
self._action_running = False
async def menu_login(self) -> None:
users = cli.list_local_users()
userid = ""
if users:
self.write_log("[login] local users:")
for i, u in enumerate(users, 1):
self.write_log(f" {i}) {u}")
pick = await self.ask("Select number or type new userid")
if pick is None or not pick:
return
if pick.isdigit() and 1 <= int(pick) <= len(users):
userid = users[int(pick) - 1]
else:
userid = pick
else:
typed = await self.ask("No local users found. New userid")
if not typed:
return
userid = typed
if not cli.user_exists(userid):
ok = await self.confirm(f"Create user '{userid}' via zkac-node user create?")
if not ok:
return
cli.create_user(userid).raise_for_status()
self.write_log(f"[login] created user {userid}")
self.userid = userid
self.identity = cli.get_identity(userid, peer=None)
self.write_log(f"[login] logged in as {userid}")
self.write_log(f" issuance pk: {self.identity.issuance_pk_hex}")
self.write_log(f" transport pk: {self.identity.transport_pk_hex}")
async def menu_identity(self) -> None:
ident = self._require_user()
peer = await self.ask("Optional peer host:port for contact bundle")
contact = cli.show_user_contact(ident.userid, peer=peer or None)
self.write_log("[identity]")
self.write_log(f" userid: {ident.userid}")
self.write_log(f" issuance pk: {ident.issuance_pk_hex}")
self.write_log(f" transport pk: {ident.transport_pk_hex}")
self.write_log(" contact bundle:")
self.write_log(f" {contact}")
self._last_contact_bundle = contact
if self._copy_to_clipboard(contact):
self.write_log(
f" copied contact bundle to clipboard via {self._last_clipboard_backend}"
)
else:
self.write_log(
" warning: could not copy contact bundle to clipboard"
f" ({self._last_clipboard_error})"
)
async def menu_connect(self) -> None:
ident = self._require_user()
server = await self.ask("File-share server host:port", self.server or "127.0.0.1:9879")
if server is None or not server:
return
pinned = cli.load_pinned_server_key(ident.userid, server)
server_pk_hex: str
if pinned and self._server_key_maybe_stale_for != server:
server_pk_hex = pinned
self.write_log(f"[connect] reused pinned key for {server} ({server_pk_hex[:16]}...)")
else:
prompt = "Server transport public key hex"
if pinned:
prompt = "Server key may be stale; enter updated transport public key hex"
server_pk_hex = await self.ask(prompt, pinned or self.server_pk_hex or "")
if server_pk_hex is None or not server_pk_hex:
return
cli.server_pin(ident.userid, server, server_pk_hex).raise_for_status()
self.write_log(f"[connect] pinned {server} = {server_pk_hex[:16]}...")
self._server_key_maybe_stale_for = None
self.server = server
self.server_pk_hex = server_pk_hex
async def menu_buckets(self) -> None:
op = await self.ask("Select bucket: l=list/select, c=create new", "l")
if op is None:
return
if op == "c":
await self._bucket_create()
return
await self._bucket_select_from_all_access()
async def _bucket_select_from_all_access(self) -> None:
ident = self._require_user()
server, _ = self._require_server()
inventory: dict[str, set[str]] = {}
# Owned/admin buckets.
for reg in cli.registry_list(ident.userid):
if reg["server"] != server:
continue
rid = reg["registry_id"]
if not cli.is_registry_admin(ident.userid, rid):
continue
try:
with self._open_session(rid, "__admin__") as sess:
for bid in sess.bucket_list_owned():
inventory.setdefault(bid, set()).add("owner")
except Exception as exc: # noqa: BLE001
self.write_log(f"[bucket] skip admin bucket list for {rid[:16]}... ({exc})")
# Buckets visible via role credentials.
for cred in cli.credentials_list(ident.userid):
rid = cred["registry_id"]
role = cred["role"]
try:
with self._open_session(rid, role) as sess:
for bid in sess.fs_buckets():
inventory.setdefault(bid, set()).add(f"role:{role}")
except Exception as exc: # noqa: BLE001
self.write_log(
f"[bucket] skip credential registry={rid[:16]}... role={role} ({exc})"
)
if not inventory:
self.write_log("[bucket] no owned or permitted buckets yet")
return
bucket_ids = sorted(inventory.keys())
options = [
f"{bid} access={sorted(inventory[bid])}"
for bid in bucket_ids
]
default = 1
if self.current_bucket_id and self.current_bucket_id in bucket_ids:
default = bucket_ids.index(self.current_bucket_id) + 1
idx = await self._choose_index("Buckets", options, default=default)
selected = bucket_ids[idx]
self.current_bucket_id = selected
if selected in fsc.list_manifests(ident.userid):
self.write_log(f"[bucket] selected {selected} (locally managed)")
else:
self.write_log(f"[bucket] selected {selected} (remote-only access)")
async def _bucket_create(self) -> None:
ident = self._require_user()
rid = await self._pick_or_create_registry_for_bucket()
folder_str = await self.ask("Path to folder to share")
if folder_str is None or not folder_str:
return
folder = Path(folder_str).expanduser().resolve()
if not folder.is_dir():
raise RuntimeError(f"not a directory: {folder}")
files = fsc.flatten_folder(folder)
if not files:
raise RuntimeError("no shareable files found (empty or hidden only)")
self.write_log(f"[bucket-create] flattened {len(files)} files")
for i, p in enumerate(files):
self.write_log(f" [{i:>3}] {p.relative_to(folder)}")
roles_meta = next(
(r for r in cli.registry_list(ident.userid) if r["registry_id"] == rid),
None,
)
if roles_meta is None:
raise RuntimeError("registry metadata not found locally")
masks: dict[str, str] = {}
for role in roles_meta["roles"]:
raw = await self.ask(
f"Mask for role '{role}' ({len(files)} bits)",
"1" * len(files),
)
if raw is None:
return
masks[role] = fsc.normalize_mask(raw, len(files))
with self._open_session(rid, "__admin__") as sess:
self.write_log("[bucket-create] uploading encrypted blobs...")
manifest = fsc.upload_bucket(
sess,
folder,
server=self.server or "",
registry_id_hex=rid,
)
manifest.role_masks = masks
with self._open_session(rid, "__admin__") as sess:
fsc.apply_role_masks_to_server(sess, manifest)
self._log_server_acl_summary(sess, manifest)
fsc.save_manifest(ident.userid, manifest)
self.current_bucket_id = manifest.bucket_id
self.write_log(f"[bucket-create] uploaded {manifest.bucket_id} ({len(manifest.files)} files)")
async def _select_local_manifest(self) -> fsc.BucketManifest:
ident = self._require_user()
bids = fsc.list_manifests(ident.userid)
if not bids:
raise RuntimeError("no local bucket manifests")
options: list[str] = []
for bid in bids:
man = fsc.load_manifest(ident.userid, bid)
options.append(
f"{bid[:16]}... files={len(man.files)} roles={sorted(man.role_masks.keys())}"
)
default = 1
if self.current_bucket_id and self.current_bucket_id in bids:
default = bids.index(self.current_bucket_id) + 1
idx = await self._choose_index("Select bucket", options, default=default)
return fsc.load_manifest(ident.userid, bids[idx])
async def _bucket_set_masks(self) -> None:
ident = self._require_user()
manifest = await self._select_local_manifest()
self.write_log(f"[bucket-masks] {manifest.bucket_id} has {len(manifest.files)} files")
for i, f in enumerate(manifest.files):
self.write_log(f" [{i:>3}] {f.rel_path}")
roles = sorted(manifest.role_masks.keys() or [])
if not roles:
roles_raw = await self.ask("Comma-separated role names", "viewer,editor")
if not roles_raw:
return
roles = [r.strip() for r in roles_raw.split(",") if r.strip()]
for role in roles:
current = manifest.role_masks.get(role, "1" * len(manifest.files))
new = await self.ask(f"Mask for role '{role}'", current)
if new is None:
return
manifest.role_masks[role] = fsc.normalize_mask(new, len(manifest.files))
with self._open_session(manifest.registry_id_hex, "__admin__") as sess:
fsc.apply_role_masks_to_server(sess, manifest)
self._log_server_acl_summary(sess, manifest)
fsc.save_manifest(ident.userid, manifest)
self.current_bucket_id = manifest.bucket_id
self.write_log("[permissions] bucket permissions updated")
async def _pick_or_create_registry_for_bucket(self) -> str:
ident = self._require_user()
server, _ = self._require_server()
regs = [r for r in cli.registry_list(ident.userid) if r["server"] == server]
if regs:
options = [
f"use {r['registry_id'][:16]}... roles={r['roles']}"
for r in regs
]
options.append("create new registry for this server")
idx = await self._choose_index("Bucket auth profile", options)
if idx < len(regs):
return regs[idx]["registry_id"]
roles_raw = await self.ask("No auth profile yet. Create roles", "viewer,editor")
if not roles_raw:
raise RuntimeError("roles required to create bucket auth profile")
roles = [r.strip() for r in roles_raw.split(",") if r.strip()]
if not roles:
raise RuntimeError("no roles provided")
rid = cli.registry_create(ident.userid, server, roles)
self.write_log(f"[bucket] created auth profile {rid} roles={roles}")
return rid
async def menu_share(self) -> None:
ident = self._require_user()
manifest = await self._select_local_manifest()
if not manifest.role_masks:
raise RuntimeError("set role masks first from Buckets")
roles = sorted(manifest.role_masks.keys())
idx = await self._choose_index("Share role", roles)
role = roles[idx]
contact = await self.ask("Recipient contact bundle")
if contact is None or not contact:
return
result = fscred.grant_role_p2p(
ident.userid,
manifest.server,
manifest.registry_id_hex,
role,
contact,
)
self.write_log(f"[share] issued role '{role}' to peer {result['peer']}")
recipient_pk_hex = _parse_contact_issuance_pk(contact)
with self._open_session(manifest.registry_id_hex, "__admin__") as sess:
fsc.apply_role_masks_to_server(sess, manifest)
self._log_server_acl_summary(sess, manifest)
fsc.push_role_grant(sess, manifest, role, recipient_pk_hex)
fsc.save_manifest(ident.userid, manifest)
self.current_bucket_id = manifest.bucket_id
self.write_log(f"[share] pushed bucket grant to {recipient_pk_hex[:16]}...")
async def menu_listen(self) -> None:
ident = self._require_user()
if self.listener and self.listener.is_running():
keep = await self.confirm(
f"Listener already running on {self.listener.address}. Stop it?"
)
if keep:
self.listener.stop()
self.listener = None
else:
return
host = await self.ask("Bind host", "127.0.0.1")
if host is None or not host:
return
raw_port = await self.ask("Optional bind port (blank = random)", "")
if raw_port is None:
return
port = 0
if raw_port.strip():
if not raw_port.isdigit():
raise RuntimeError("port must be a number or blank")
port = int(raw_port)
if not 1 <= port <= 65535:
raise RuntimeError("port out of range")
listener = cli.P2PListener(ident.userid, host=host, port=port)
listener.start()
time.sleep(0.3)
if not listener.is_running():
self.write_log(f"[listen] listener exited:\n{listener.output()}")
return
contact = cli.show_user_contact(ident.userid, peer=listener.address)
self.listener = listener
self.write_log(f"[listen] listening on {listener.address} timeout={listener.timeout_s:.0f}s")
self.write_log("Share this contact bundle out-of-band:")
self.write_log(contact)
self._last_contact_bundle = contact
if self._copy_to_clipboard(contact):
self.write_log(
f"[listen] copied contact bundle to clipboard via {self._last_clipboard_backend}"
)
else:
self.write_log(
"[listen] warning: could not copy contact bundle to clipboard"
f" ({self._last_clipboard_error})"
)
async def menu_inbox(self) -> None:
ident = self._require_user()
creds: list[dict[str, str]] = list(cli.credentials_list(ident.userid))
for reg in cli.registry_list(ident.userid):
rid = reg["registry_id"]
if cli.is_registry_admin(ident.userid, rid):
creds.append({"registry_id": rid, "role": "__admin__"})
if not creds:
self.write_log("[inbox] no local credentials; ask an admin to grant one")
return
unique: dict[tuple[str, str], dict[str, str]] = {}
for c in creds:
unique[(c["registry_id"], c["role"])] = c
creds = list(unique.values())
opts = [f"registry={c['registry_id'][:16]}... role={c['role']}" for c in creds]
idx = await self._choose_index("Credential for inbox", opts)
cred = creds[idx]
with self._open_session(cred["registry_id"], cred["role"]) as sess:
who = sess.whoami()
self.write_log(
f"[inbox] authenticated registry={who['registry_id'][:16]}... "
f"role_id={who['role_id'][:16]}..."
)
if cred["role"] == "__admin__":
bids = sess.bucket_list_owned()
else:
bids = sess.fs_buckets()
if not bids:
self.write_log("[inbox] no accessible buckets")
return
self.write_log("[inbox] accessible buckets:")
for i, bid in enumerate(bids, 1):
self.write_log(f" {i}) {bid}")
pick = await self.ask("Download bucket number, 'all', or blank to skip", "1")
if pick is None or not pick:
return
if pick == "all":
targets = list(range(len(bids)))
elif pick.isdigit():
targets = [int(pick) - 1]
else:
raise RuntimeError("invalid selection")
for ti in targets:
if not 0 <= ti < len(bids):
raise RuntimeError("bucket index out of range")
out_default = str(Path.cwd() / "fs_inbox")
out = await self.ask("Download root folder", out_default)
if out is None or not out:
return
out_root = Path(out).expanduser().resolve()
for ti in targets:
bid = bids[ti]
bucket_dir = out_root / bid
if cred["role"] == "__admin__":
result = self._download_owned_bucket_as_admin(
sess=sess,
userid=ident.userid,
bucket_id=bid,
output_dir=bucket_dir,
)
else:
result = fsc.download_bucket(
sess,
bid,
issuance_secret_hex=ident.issuance_secret_hex,
output_dir=bucket_dir,
)
self.write_log(
f"[inbox] downloaded {bid} -> {bucket_dir} "
f"({len(result['files_written'])} files)"
)
def _download_owned_bucket_as_admin(
self,
*,
sess: fsc.FileShareSession,
userid: str,
bucket_id: str,
output_dir: Path,
) -> dict:
manifest = fsc.load_manifest(userid, bucket_id)
output_dir.mkdir(parents=True, exist_ok=True)
written: list[str] = []
for entry in manifest.files:
ciphertexts = [sess.fs_get_blob(bucket_id, c.blob_id) for c in entry.chunks]
plaintext = fsc.decrypt_file_from_blobs(entry, ciphertexts)
out_path = output_dir / entry.rel_path
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(plaintext)
written.append(str(out_path))
return {"files_written": written}
def _log_server_acl_summary(self, sess: fsc.FileShareSession, manifest: fsc.BucketManifest) -> None:
for role_name, raw_mask in sorted(manifest.role_masks.items()):
expected = sum(1 for bit in fsc.normalize_mask(raw_mask, len(manifest.files)) if bit == "1")
role_id_hex = zkac.role_id(role_name).hex()
acl_meta = sess.bucket_get_role_acl(manifest.bucket_id, role_id_hex)
allowed_blob_ids = list(acl_meta.get("allowed_blob_ids", []))
acl_version = int(acl_meta.get("version", 0))
self.write_log(
f"[permissions] server ACL role={role_name} "
f"version={acl_version} files(expected)={expected} blobs(server)={len(allowed_blob_ids)}"
)
def _copy_to_clipboard(self, text: str) -> bool:
text = text.strip()
if not text:
self._last_clipboard_error = "empty text"
self._last_clipboard_backend = ""
return False
is_st = os.environ.get("TERM", "").startswith("st")
# In st, prefer explicit system clipboard tools; Textual clipboard often reports
# success but doesn't reach the desktop clipboard.
if not is_st:
try:
self.copy_to_clipboard(text)
self._last_clipboard_error = ""
self._last_clipboard_backend = "textual"
return True
except Exception:
self._last_clipboard_error = "Textual clipboard unavailable"
def _run_clip(cmd: list[str], payload: str) -> subprocess.CompletedProcess[str] | None:
try:
return subprocess.run(
cmd,
input=payload,
text=True,
capture_output=True,
timeout=0.8,
)
except subprocess.TimeoutExpired:
self._last_clipboard_error = f"{cmd[0]} timed out"
return None
except Exception as exc:
self._last_clipboard_error = f"{cmd[0]} failed: {exc}"
return None
# Wayland
if shutil.which("wl-copy") and os.environ.get("WAYLAND_DISPLAY"):
p = _run_clip(["wl-copy"], text)
if p and p.returncode == 0:
self._last_clipboard_error = ""
self._last_clipboard_backend = "wl-copy"
return True
if p:
self._last_clipboard_error = (p.stderr or p.stdout or "wl-copy failed").strip()
# X11: prefer xsel first (often exits faster), then xclip.
if shutil.which("xsel") and os.environ.get("DISPLAY"):
p1 = _run_clip(["xsel", "--clipboard", "--input"], text)
p2 = _run_clip(["xsel", "--primary", "--input"], text)
if (p1 and p1.returncode == 0) or (p2 and p2.returncode == 0):
self._last_clipboard_error = ""
self._last_clipboard_backend = "xsel"
return True
if p1 or p2:
self._last_clipboard_error = (
(p1.stderr if p1 else "")
or (p2.stderr if p2 else "")
or (p1.stdout if p1 else "")
or (p2.stdout if p2 else "")
or "xsel failed"
).strip()
if shutil.which("xclip") and os.environ.get("DISPLAY"):
p1 = _run_clip(["xclip", "-selection", "clipboard", "-in"], text)
p2 = _run_clip(["xclip", "-selection", "primary", "-in"], text)
if (p1 and p1.returncode == 0) or (p2 and p2.returncode == 0):
self._last_clipboard_error = ""
self._last_clipboard_backend = "xclip"
return True
if p1 or p2:
self._last_clipboard_error = (
(p1.stderr if p1 else "")
or (p2.stderr if p2 else "")
or (p1.stdout if p1 else "")
or (p2.stdout if p2 else "")
or "xclip failed"
).strip()
if shutil.which("pbcopy"):
p = _run_clip(["pbcopy"], text)
if p and p.returncode == 0:
self._last_clipboard_error = ""
self._last_clipboard_backend = "pbcopy"
return True
if p:
self._last_clipboard_error = (p.stderr or p.stdout or "pbcopy failed").strip()
# Final fallback for terminals that support OSC52 clipboard control.
try:
encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
sys.__stdout__.write(f"\033]52;c;{encoded}\a")
sys.__stdout__.flush()
self._last_clipboard_error = ""
self._last_clipboard_backend = "osc52"
return True
except Exception as exc:
self._last_clipboard_error = f"OSC52 failed: {exc}"
self._last_clipboard_backend = ""
if is_st:
self._last_clipboard_error = (
"st detected; install xsel/xclip and ensure DISPLAY is set"
)
return False
def action_copy_contact(self) -> None:
if not self._last_contact_bundle:
self.write_log("[copy-contact] no contact bundle generated yet")
return
if self._copy_to_clipboard(self._last_contact_bundle):
self.write_log(
f"[copy-contact] copied latest contact bundle via {self._last_clipboard_backend}"
)
return
self.write_log(
"[copy-contact] failed to copy latest contact bundle"
f" ({self._last_clipboard_error})"
)
def _parse_contact_issuance_pk(bundle: str) -> str:
s = bundle.strip()
raw = base64.urlsafe_b64decode((s + "=" * (-len(s) % 4)).encode())
data = json.loads(raw.decode("utf-8"))
pk = data.get("issuance_pk_hex")
if not isinstance(pk, str) or len(pk) != 64:
raise RuntimeError("contact bundle missing issuance_pk_hex")
return pk
def main() -> None:
FileShareApp().run()
if __name__ == "__main__":
main()