shelldeck 0.0.1__tar.gz → 0.0.2__tar.gz

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.
Files changed (49) hide show
  1. {shelldeck-0.0.1 → shelldeck-0.0.2}/CHANGELOG.md +15 -0
  2. {shelldeck-0.0.1 → shelldeck-0.0.2}/PKG-INFO +15 -2
  3. {shelldeck-0.0.1 → shelldeck-0.0.2}/README.md +13 -1
  4. {shelldeck-0.0.1 → shelldeck-0.0.2}/pyproject.toml +2 -1
  5. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/cli.py +46 -0
  6. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/server.py +71 -7
  7. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/app.css +69 -0
  8. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/app.js +36 -1
  9. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/index.html +6 -1
  10. {shelldeck-0.0.1 → shelldeck-0.0.2}/tests/test_cli.py +24 -0
  11. {shelldeck-0.0.1 → shelldeck-0.0.2}/tests/test_shelldeck.py +34 -0
  12. {shelldeck-0.0.1 → shelldeck-0.0.2}/.gitignore +0 -0
  13. {shelldeck-0.0.1 → shelldeck-0.0.2}/LICENSE +0 -0
  14. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/__init__.py +0 -0
  15. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/__main__.py +0 -0
  16. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/addons/__init__.py +0 -0
  17. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/addons/fast_context.py +0 -0
  18. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/auth.py +0 -0
  19. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/db.py +0 -0
  20. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/gitgraph.py +0 -0
  21. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/integration/bash.sh +0 -0
  22. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/integration/shelldeck.fish +0 -0
  23. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/integration/shelldeck.ps1 +0 -0
  24. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/integration/zsh/.zshenv +0 -0
  25. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/integration/zsh/.zshrc +0 -0
  26. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/pty.py +0 -0
  27. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/runner.py +0 -0
  28. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/scheduler.py +0 -0
  29. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/shells.py +0 -0
  30. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/gitgraph.js +0 -0
  31. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/history.js +0 -0
  32. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/icon-192.png +0 -0
  33. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/icon-32.png +0 -0
  34. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/icon-512.png +0 -0
  35. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/icon.svg +0 -0
  36. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/manifest.webmanifest +0 -0
  37. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/monitor.js +0 -0
  38. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/ui.js +0 -0
  39. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/vendor/LICENSE-xterm.txt +0 -0
  40. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/vendor/addon-fit.js +0 -0
  41. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/vendor/addon-search.js +0 -0
  42. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/vendor/addon-serialize.js +0 -0
  43. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/vendor/addon-web-links.js +0 -0
  44. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/vendor/addon-webgl.js +0 -0
  45. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/vendor/xterm.css +0 -0
  46. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/vendor/xterm.js +0 -0
  47. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/views.js +0 -0
  48. {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/stats.py +0 -0
  49. {shelldeck-0.0.1 → shelldeck-0.0.2}/tests/test_gitgraph.py +0 -0
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to shelldeck are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## [0.0.2] - 2026-09-29
6
+
7
+ ### Added
8
+
9
+ - `sd share`: reach shelldeck from another device over an HTTPS Cloudflare quick tunnel (needs `cloudflared`, no account). It prints a QR code and a one-per-share link; the tunnel address is refused without that link, and your password is still required. Ctrl+C stops sharing and signs those browsers out.
10
+ - Phone key bar on touch screens: Esc, Tab, a sticky Ctrl, arrows and `| ~ / -`.
11
+
12
+ ### Changed
13
+
14
+ - Touch devices use xterm's DOM renderer (WebGL came up blank on high-DPI phones).
15
+ - History rows and the Settings dialog stack into one column on narrow screens.
16
+ - Requests through a proxy that sets `CF-Connecting-IP` count as remote.
17
+ - CI actions updated: checkout v7, setup-uv v7, upload-artifact v7, download-artifact v8.
18
+
5
19
  ## [0.0.1] - 2026-09-28
6
20
 
7
21
  First public release.
@@ -18,4 +32,5 @@ First public release.
18
32
  - Mandatory password with per-browser logins, idle lock, and HTTPS or SSH-tunnel remote access.
19
33
  - `sd` CLI with a startup banner, `sd search` (LLM-free code search), and one-line installers for Windows and Linux.
20
34
 
35
+ [0.0.2]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.2
21
36
  [0.0.1]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: shelldeck
3
- Version: 0.0.1
3
+ Version: 0.0.2
4
4
  Summary: Project-first multi-terminal for Windows and Linux: real shells, grouped by project, in your browser
5
5
  Project-URL: Homepage, https://github.com/codejunction/shelldeck
6
6
  Project-URL: Source, https://github.com/codejunction/shelldeck
@@ -28,6 +28,7 @@ Requires-Dist: fastapi>=0.110
28
28
  Requires-Dist: psutil>=5.9
29
29
  Requires-Dist: pywinpty>=2.0; sys_platform == 'win32'
30
30
  Requires-Dist: rich>=13
31
+ Requires-Dist: segno>=1.6
31
32
  Requires-Dist: typer>=0.12
32
33
  Requires-Dist: uvicorn[standard]>=0.29
33
34
  Description-Content-Type: text/markdown
@@ -43,7 +44,7 @@ Description-Content-Type: text/markdown
43
44
  Real shells (PowerShell, cmd, Git Bash, WSL, bash, zsh, fish) grouped by project,<br>
44
45
  in split or free-floating panes that survive restarts.
45
46
 
46
- [![PyPI](https://img.shields.io/pypi/v/shelldeck?color=8b5cf6&label=pypi)](https://pypi.org/project/shelldeck/)
47
+ [![PyPI](https://img.shields.io/pypi/v/shelldeck?color=8b5cf6&cacheSeconds=3600)](https://pypi.org/project/shelldeck/)
47
48
  [![Python](https://img.shields.io/pypi/pyversions/shelldeck?color=8b5cf6)](https://pypi.org/project/shelldeck/)
48
49
  [![CI](https://github.com/codejunction/shelldeck/actions/workflows/ci.yml/badge.svg)](https://github.com/codejunction/shelldeck/actions/workflows/ci.yml)
49
50
  [![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20Linux-8b5cf6)](#install)
@@ -194,6 +195,7 @@ sd schedule list|add|run|toggle|delete|logs
194
195
  sd task list|add|move|delete|alarms
195
196
  sd serve [--host H] run the server in the foreground
196
197
  sd stop stop the background server (closes all terminals)
198
+ sd share share over an HTTPS Cloudflare tunnel with a QR link (needs cloudflared)
197
199
  sd login-link emergency: one-time login URL (host only)
198
200
  sd reset-password emergency: forget the password (host only)
199
201
  ```
@@ -215,6 +217,17 @@ sd serve # on the VM: stays on 127.0.0.1:5455
215
217
  ssh -N -L 5455:127.0.0.1:5455 you@vm # on your machine, then open http://127.0.0.1:5455
216
218
  ```
217
219
 
220
+ **`sd share` (any device, no setup).** Needs [`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) (`winget install Cloudflare.cloudflared`); no Cloudflare account.
221
+
222
+ ```sh
223
+ sd share # starts shelldeck if needed, prints a QR code and a link; Ctrl+C stops sharing
224
+ ```
225
+
226
+ - **Encrypted.** The other device talks HTTPS to Cloudflare, which relays it through cloudflared's outbound encrypted tunnel to shelldeck on `127.0.0.1`. No ports are opened.
227
+ - **Two locks.** The random `trycloudflare.com` address alone is refused; only the printed link (a one-per-share token made on the host) lets a browser in, and then it still needs your password. A password must exist before sharing.
228
+ - **Phones.** On touch screens a key bar adds Esc, Tab, Ctrl (applies to the next letter), arrows and `| ~ / -`; dialogs open as bottom sheets.
229
+ - **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it. Each `sd share` gets a new address and link.
230
+
218
231
  **HTTPS on the VM's address,** with a real certificate (Tailscale `tailscale cert`, Let's Encrypt, your reverse proxy) or a self-signed one:
219
232
 
220
233
  ```sh
@@ -9,7 +9,7 @@
9
9
  Real shells (PowerShell, cmd, Git Bash, WSL, bash, zsh, fish) grouped by project,<br>
10
10
  in split or free-floating panes that survive restarts.
11
11
 
12
- [![PyPI](https://img.shields.io/pypi/v/shelldeck?color=8b5cf6&label=pypi)](https://pypi.org/project/shelldeck/)
12
+ [![PyPI](https://img.shields.io/pypi/v/shelldeck?color=8b5cf6&cacheSeconds=3600)](https://pypi.org/project/shelldeck/)
13
13
  [![Python](https://img.shields.io/pypi/pyversions/shelldeck?color=8b5cf6)](https://pypi.org/project/shelldeck/)
14
14
  [![CI](https://github.com/codejunction/shelldeck/actions/workflows/ci.yml/badge.svg)](https://github.com/codejunction/shelldeck/actions/workflows/ci.yml)
15
15
  [![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20Linux-8b5cf6)](#install)
@@ -160,6 +160,7 @@ sd schedule list|add|run|toggle|delete|logs
160
160
  sd task list|add|move|delete|alarms
161
161
  sd serve [--host H] run the server in the foreground
162
162
  sd stop stop the background server (closes all terminals)
163
+ sd share share over an HTTPS Cloudflare tunnel with a QR link (needs cloudflared)
163
164
  sd login-link emergency: one-time login URL (host only)
164
165
  sd reset-password emergency: forget the password (host only)
165
166
  ```
@@ -181,6 +182,17 @@ sd serve # on the VM: stays on 127.0.0.1:5455
181
182
  ssh -N -L 5455:127.0.0.1:5455 you@vm # on your machine, then open http://127.0.0.1:5455
182
183
  ```
183
184
 
185
+ **`sd share` (any device, no setup).** Needs [`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) (`winget install Cloudflare.cloudflared`); no Cloudflare account.
186
+
187
+ ```sh
188
+ sd share # starts shelldeck if needed, prints a QR code and a link; Ctrl+C stops sharing
189
+ ```
190
+
191
+ - **Encrypted.** The other device talks HTTPS to Cloudflare, which relays it through cloudflared's outbound encrypted tunnel to shelldeck on `127.0.0.1`. No ports are opened.
192
+ - **Two locks.** The random `trycloudflare.com` address alone is refused; only the printed link (a one-per-share token made on the host) lets a browser in, and then it still needs your password. A password must exist before sharing.
193
+ - **Phones.** On touch screens a key bar adds Esc, Tab, Ctrl (applies to the next letter), arrows and `| ~ / -`; dialogs open as bottom sheets.
194
+ - **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it. Each `sd share` gets a new address and link.
195
+
184
196
  **HTTPS on the VM's address,** with a real certificate (Tailscale `tailscale cert`, Let's Encrypt, your reverse proxy) or a self-signed one:
185
197
 
186
198
  ```sh
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "shelldeck"
3
- version = "0.0.1"
3
+ version = "0.0.2"
4
4
  description = "Project-first multi-terminal for Windows and Linux: real shells, grouped by project, in your browser"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -29,6 +29,7 @@ dependencies = [
29
29
  "rich>=13",
30
30
  "apscheduler>=3.10,<4",
31
31
  "psutil>=5.9",
32
+ "segno>=1.6",
32
33
  ]
33
34
 
34
35
  [project.urls]
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import os
3
+ import re
3
4
  import shutil
4
5
  import ssl
5
6
  import subprocess
@@ -301,6 +302,51 @@ def login_link():
301
302
  typer.echo("One use, valid for 5 minutes. Through an SSH tunnel, open it on your machine as-is.")
302
303
 
303
304
 
305
+ def _show_share(url: str) -> None:
306
+ import segno
307
+
308
+ if sys.stdout.isatty():
309
+ segno.make(url, error="l").terminal(compact=(sys.stdout.encoding or "").lower().startswith("utf"))
310
+ typer.echo(f"\n {url}\n")
311
+ typer.echo(" Scan or open it on the other device, then log in with your shelldeck password.")
312
+ typer.echo(" Without this link the tunnel URL is refused. Ctrl+C stops sharing and signs those browsers out.")
313
+
314
+
315
+ @app.command()
316
+ def share():
317
+ """Reach this shelldeck from another device: HTTPS Cloudflare quick tunnel, QR link, then your password."""
318
+ exe = shutil.which("cloudflared")
319
+ if not exe:
320
+ hint = "winget install Cloudflare.cloudflared" if sys.platform == "win32" else "see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
321
+ typer.echo(f"cloudflared is not installed ({hint})", err=True)
322
+ raise typer.Exit(1)
323
+ ensure_server()
324
+ if not _api("/api/auth/status").get("has_password"):
325
+ typer.echo("Set a password first: open shelldeck on this machine (`sd`), then run `sd share` again.", err=True)
326
+ raise typer.Exit(1)
327
+ # the tunnel ends at our own loopback server, whose certificate (if any) is self-signed
328
+ cmd = [exe, "tunnel", "--no-autoupdate", "--url", _url()] + (["--no-tls-verify"] if _scheme() == "https" else [])
329
+ proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, errors="replace")
330
+ host = shared = None
331
+ try:
332
+ for line in proc.stderr: # cloudflared logs to stderr: the URL first, then each edge connection
333
+ if not host and (m := re.search(r"https://([a-z0-9-]+\.trycloudflare\.com)", line)):
334
+ host = m.group(1)
335
+ elif host and not shared and "Registered tunnel connection" in line:
336
+ shared = f"https://{host}" + _api("/api/share", "POST", {"host": host})["path"]
337
+ _show_share(shared)
338
+ except KeyboardInterrupt:
339
+ pass
340
+ finally:
341
+ proc.terminate()
342
+ if shared and _health() == "ok":
343
+ _api("/api/share", "DELETE")
344
+ if not shared:
345
+ typer.echo("cloudflared exited without a tunnel URL; run it by hand to see why.", err=True)
346
+ raise typer.Exit(1)
347
+ typer.echo("sharing stopped")
348
+
349
+
304
350
  @app.command()
305
351
  def stop():
306
352
  """Stop the background server (closes all terminals)."""
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import hashlib
2
3
  import hmac
3
4
  import json
4
5
  import logging
@@ -67,10 +68,28 @@ def err(code: str, status: int = 400) -> JSONResponse:
67
68
  return JSONResponse({"error": code}, status_code=status)
68
69
 
69
70
 
70
- def _trusted(headers) -> bool:
71
- """Same-origin check. Browsers always send Origin cross-site; the CLI sends none."""
71
+ # `sd share`: the tunnel's public hostname, sha256 of its link token, and the logins made through it
72
+ _share: dict = {}
73
+ SHARE_COOKIE = "sd_share"
74
+
75
+
76
+ def _share_key(token: str | None) -> bool:
77
+ return bool(token and _share) and hmac.compare_digest(hashlib.sha256(token.encode()).hexdigest(), _share["key"])
78
+
79
+
80
+ def _via_share(conn) -> bool:
81
+ return bool(_share) and conn.headers.get("host", "").rsplit(":", 1)[0] == _share["host"]
82
+
83
+
84
+ def _trusted(conn) -> bool:
85
+ """Same-origin check. Browsers always send Origin cross-site; the CLI sends none.
86
+ The share tunnel's host also needs the cookie its link sets (or to be opening that link)."""
87
+ headers = conn.headers
72
88
  host = headers.get("host", "")
73
- if ALLOWED_HOSTS is not None and host.rsplit(":", 1)[0] not in ALLOWED_HOSTS:
89
+ if _via_share(conn):
90
+ if not (conn.url.path.startswith("/share/") or _share_key(conn.cookies.get(SHARE_COOKIE))):
91
+ return False
92
+ elif ALLOWED_HOSTS is not None and host.rsplit(":", 1)[0] not in ALLOWED_HOSTS:
74
93
  return False
75
94
  origin = headers.get("origin")
76
95
  return not origin or urlsplit(origin).netloc == host
@@ -152,7 +171,8 @@ def _client(conn) -> str:
152
171
 
153
172
  def _is_local(conn) -> bool:
154
173
  """A request from the host itself (not through a reverse proxy)."""
155
- return _client(conn) in LOOPBACK and "x-forwarded-for" not in conn.headers
174
+ proxied = "x-forwarded-for" in conn.headers or "cf-connecting-ip" in conn.headers # e.g. `sd share`
175
+ return _client(conn) in LOOPBACK and not proxied
156
176
 
157
177
 
158
178
  def _who(conn) -> tuple[str, str | None] | None:
@@ -169,7 +189,7 @@ def _denied() -> JSONResponse:
169
189
 
170
190
  @app.middleware("http")
171
191
  async def guard(request: Request, call_next):
172
- if not _trusted(request.headers):
192
+ if not _trusted(request):
173
193
  return err("forbidden_origin", 403)
174
194
  path = request.url.path
175
195
  request.state.session = None
@@ -217,6 +237,8 @@ async def shutdown():
217
237
  def _login_response(request: Request, body: dict, res=None):
218
238
  """Start a login session: cookie on `res` (JSON body by default)."""
219
239
  token = auth.new_session(f"{_client(request)} {request.headers.get('user-agent', '')}")
240
+ if _via_share(request):
241
+ _share["sessions"].add(auth.session_hash(token))
220
242
  res = res or JSONResponse(body)
221
243
  res.set_cookie(
222
244
  auth.COOKIE, token, httponly=True, samesite="strict", path="/",
@@ -293,6 +315,48 @@ async def auth_login_link(request: Request):
293
315
  return {"path": f"/api/auth/link/{code}"}
294
316
 
295
317
 
318
+ @app.post("/api/share")
319
+ async def share_start(request: Request, payload: dict):
320
+ """Host CLI only: open the gate for an `sd share` tunnel host; returns its one link."""
321
+ if not auth.cli_ok(request.headers.get(auth.TOKEN_HEADER)):
322
+ return err("host_cli_only", 403)
323
+ host = str(payload.get("host", "")).lower()
324
+ if not re.fullmatch(r"[a-z0-9-]+(\.[a-z0-9-]+)+", host):
325
+ return err("invalid_host")
326
+ await _share_stop()
327
+ token = secrets.token_urlsafe(32)
328
+ _share.update(host=host, key=hashlib.sha256(token.encode()).hexdigest(), sessions=set())
329
+ log.info("sharing via %s", host)
330
+ return {"path": f"/share/{token}"}
331
+
332
+
333
+ @app.delete("/api/share")
334
+ async def share_stop(request: Request):
335
+ if not auth.cli_ok(request.headers.get(auth.TOKEN_HEADER)):
336
+ return err("host_cli_only", 403)
337
+ await _share_stop()
338
+ return {"status": "ok"}
339
+
340
+
341
+ async def _share_stop() -> None:
342
+ """Close the gate and sign out every browser that logged in through the tunnel."""
343
+ sessions = _share.get("sessions", set())
344
+ _share.clear()
345
+ for h in sessions:
346
+ auth.end_session(h)
347
+ await _close_session_sockets(lambda owner: owner in sessions)
348
+
349
+
350
+ @app.get("/share/{token}")
351
+ async def share_open(request: Request, token: str):
352
+ """The QR link: sets the tunnel cookie, then the usual password login takes over."""
353
+ if not (_via_share(request) and _share_key(token)):
354
+ return err("link_expired", 403)
355
+ res = RedirectResponse("/", status_code=303) # drops the token from the address bar
356
+ res.set_cookie(SHARE_COOKIE, token, httponly=True, samesite="lax", secure=True, path="/", max_age=24 * 3600)
357
+ return res
358
+
359
+
296
360
  @app.get("/api/auth/link/{code}")
297
361
  async def auth_use_link(request: Request, code: str):
298
362
  expiry = _login_links.pop(code, 0)
@@ -825,7 +889,7 @@ def _attach(session: dict, rows: int, cols: int) -> None:
825
889
 
826
890
  @app.websocket("/ws/alarms")
827
891
  async def alarms_ws(ws: WebSocket):
828
- who = _who(ws) if _trusted(ws.headers) else None
892
+ who = _who(ws) if _trusted(ws) else None
829
893
  if not who:
830
894
  await ws.close(code=1008)
831
895
  return
@@ -846,7 +910,7 @@ async def alarms_ws(ws: WebSocket):
846
910
 
847
911
  @app.websocket("/ws/{session_id}")
848
912
  async def terminal_ws(ws: WebSocket, session_id: str):
849
- who = _who(ws) if _trusted(ws.headers) else None
913
+ who = _who(ws) if _trusted(ws) else None
850
914
  if not who:
851
915
  await ws.close(code=1008)
852
916
  return
@@ -1700,7 +1700,42 @@ textarea:focus {
1700
1700
  }
1701
1701
  }
1702
1702
 
1703
+ #keybar {
1704
+ display: none;
1705
+ }
1706
+
1703
1707
  @media (pointer: coarse) {
1708
+ #keybar {
1709
+ display: flex;
1710
+ gap: 4px;
1711
+ padding: 4px 6px calc(4px + env(safe-area-inset-bottom));
1712
+ overflow-x: auto;
1713
+ border-top: 1px solid var(--border);
1714
+ background: var(--bg);
1715
+ }
1716
+
1717
+ #main:has(#view-terminals[hidden]) #keybar,
1718
+ #main:has(#layout:empty) #keybar {
1719
+ display: none;
1720
+ }
1721
+
1722
+ #keybar button {
1723
+ flex: 1 0 auto;
1724
+ min-width: 40px;
1725
+ height: 36px;
1726
+ border: 1px solid var(--border);
1727
+ border-radius: 6px;
1728
+ background: var(--panel);
1729
+ color: var(--text);
1730
+ font: 500 13px "Cascadia Mono", Consolas, monospace;
1731
+ }
1732
+
1733
+ #keybar button[aria-pressed="true"] {
1734
+ background: var(--accent);
1735
+ border-color: var(--accent);
1736
+ color: var(--primary-fg);
1737
+ }
1738
+
1704
1739
  .proj-row,
1705
1740
  .sess-row,
1706
1741
  .sb-link,
@@ -2286,3 +2321,37 @@ textarea:focus {
2286
2321
  color: var(--accent);
2287
2322
  border-radius: 2px;
2288
2323
  }
2324
+
2325
+ /* ------------------------------------------------------------ phones */
2326
+
2327
+ @media (max-width: 560px) {
2328
+ .field-row {
2329
+ grid-template-columns: minmax(0, 1fr);
2330
+ }
2331
+
2332
+ /* history rows stack: command on top, then time / exit / duration, then actions */
2333
+ .hist-table tr {
2334
+ display: grid;
2335
+ grid-template-columns: minmax(0, 1fr) auto auto;
2336
+ align-items: center;
2337
+ column-gap: 10px;
2338
+ }
2339
+
2340
+ .hist-table td {
2341
+ border: 0 !important;
2342
+ }
2343
+
2344
+ .hist-table tr + tr {
2345
+ border-top: 1px solid var(--border);
2346
+ }
2347
+
2348
+ .hist-table .hist-cmd {
2349
+ grid-column: 1 / -1;
2350
+ grid-row: 1;
2351
+ max-width: none;
2352
+ }
2353
+
2354
+ .hist-table .hist-actions {
2355
+ grid-column: 1 / -1;
2356
+ }
2357
+ }
@@ -197,6 +197,7 @@ class Term {
197
197
  this.replaying = false;
198
198
  this.xterm.onData((data) => {
199
199
  if (this.replaying) return;
200
+ data = withCtrl(data);
200
201
  this.track(data);
201
202
  this.send({ type: "input", data });
202
203
  });
@@ -378,7 +379,8 @@ class Term {
378
379
  container.append(this.el);
379
380
  if (!this.opened) {
380
381
  this.xterm.open(this.el);
381
- try {
382
+ // phones: WebGL canvases came up blank at high pixel ratios; the DOM renderer is fine there
383
+ if (!matchMedia("(pointer: coarse)").matches) try {
382
384
  const gl = new WebglAddon.WebglAddon();
383
385
  gl.onContextLoss(() => gl.dispose());
384
386
  this.xterm.loadAddon(gl);
@@ -1777,9 +1779,42 @@ function connectAlarms() {
1777
1779
  };
1778
1780
  }
1779
1781
 
1782
+ // ----------------------------------------------------------------- phone keys
1783
+
1784
+ // keys a phone keyboard lacks; Ctrl applies to the next typed letter
1785
+ const KEYS = { esc: "", tab: " ", up: "", down: "", right: "", left: "" };
1786
+ let ctrlNext = false;
1787
+
1788
+ function setCtrl(on) {
1789
+ ctrlNext = on;
1790
+ $("#keybar [data-key=ctrl]").setAttribute("aria-pressed", String(on));
1791
+ }
1792
+
1793
+ function withCtrl(data) {
1794
+ if (!ctrlNext || data.length !== 1) return data;
1795
+ setCtrl(false);
1796
+ const c = data.toUpperCase().charCodeAt(0);
1797
+ return c >= 64 && c < 96 ? String.fromCharCode(c - 64) : data;
1798
+ }
1799
+
1800
+ function wireKeybar() {
1801
+ const bar = $("#keybar");
1802
+ bar.addEventListener("pointerdown", (e) => e.preventDefault()); // keep the soft keyboard open
1803
+ bar.addEventListener("click", (e) => {
1804
+ const k = e.target.closest("[data-key]")?.dataset.key;
1805
+ const t = S.terms.get(S.focused);
1806
+ if (!k || !t) return;
1807
+ if (k === "ctrl") return setCtrl(!ctrlNext);
1808
+ const data = withCtrl(KEYS[k] ?? k);
1809
+ t.track(data);
1810
+ t.send({ type: "input", data });
1811
+ });
1812
+ }
1813
+
1780
1814
  // ----------------------------------------------------------------- init
1781
1815
 
1782
1816
  function wireGlobal() {
1817
+ wireKeybar();
1783
1818
  document.addEventListener("keydown", onKey, true);
1784
1819
  document.addEventListener("click", (e) => {
1785
1820
  const a = e.target.closest("[data-action]");
@@ -2,7 +2,7 @@
2
2
  <html lang="en" data-theme="dark">
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
6
6
  <meta name="color-scheme" content="dark light" />
7
7
  <title>shelldeck</title>
8
8
  <link rel="icon" href="/static/icon.svg" type="image/svg+xml" />
@@ -88,6 +88,11 @@
88
88
  <section id="view-tasks" class="view page" hidden></section>
89
89
  <section id="view-monitor" class="view page" hidden></section>
90
90
  <section id="view-history" class="view page" hidden></section>
91
+ <div id="keybar" aria-label="Terminal keys">
92
+ <button data-key="esc">Esc</button><button data-key="tab">Tab</button><button data-key="ctrl" aria-pressed="false">Ctrl</button>
93
+ <button data-key="left" aria-label="Left">&larr;</button><button data-key="up" aria-label="Up">&uarr;</button><button data-key="down" aria-label="Down">&darr;</button><button data-key="right" aria-label="Right">&rarr;</button>
94
+ <button data-key="|">|</button><button data-key="~">~</button><button data-key="/">/</button><button data-key="-">-</button>
95
+ </div>
91
96
  </main>
92
97
  </div>
93
98
 
@@ -110,3 +110,27 @@ def test_search_formats(codebase):
110
110
  def test_search_rejects_bad_format(codebase):
111
111
  r = runner.invoke(cli.app, ["search", "x", "--root", str(codebase), "-f", "xml"])
112
112
  assert r.exit_code == 1
113
+
114
+
115
+ def test_share_waits_for_tunnel_then_prints_link_and_cleans_up(monkeypatch, capsys):
116
+ calls = []
117
+
118
+ class FakeTunnel:
119
+ stderr = iter([
120
+ "INF | https://abc-def.trycloudflare.com |\n",
121
+ "INF Registered tunnel connection connIndex=0\n",
122
+ ])
123
+ terminate = lambda self: calls.append("terminate") # noqa: E731
124
+
125
+ def api(path, method="GET", payload=None):
126
+ calls.append((method, path))
127
+ return {"has_password": True} if path == "/api/auth/status" else {"path": "/share/tok"}
128
+
129
+ monkeypatch.setattr(cli.shutil, "which", lambda name: "cloudflared")
130
+ monkeypatch.setattr(cli, "ensure_server", lambda: None)
131
+ monkeypatch.setattr(cli, "_health", lambda: "ok")
132
+ monkeypatch.setattr(cli, "_api", api)
133
+ monkeypatch.setattr(cli.subprocess, "Popen", lambda argv, **kw: calls.append(argv[1:4]) or FakeTunnel())
134
+ cli.share()
135
+ assert "https://abc-def.trycloudflare.com/share/tok" in capsys.readouterr().out
136
+ assert calls[1:] == [["tunnel", "--no-autoupdate", "--url"], ("POST", "/api/share"), "terminate", ("DELETE", "/api/share")]
@@ -366,3 +366,37 @@ def test_projects_get_distinct_colors(client, tmp_path):
366
366
  assert colors == [0, 1, 2]
367
367
  assert client.put("/api/settings", json={"layout_mode": "free"}).json()["layout_mode"] == "free"
368
368
  assert client.put("/api/settings", json={"layout_mode": "grid"}).status_code == 400
369
+
370
+
371
+ def test_share_gate_needs_link_then_password(browser):
372
+ b = browser
373
+ b.post("/api/auth/setup", json={"password": "longenough", "confirm": "longenough"})
374
+ cli = {"X-Shelldeck-Token": auth.read_cli_token()}
375
+ tunnel = {"host": "abc-def.trycloudflare.com", "cf-connecting-ip": "203.0.113.9", "x-forwarded-for": "203.0.113.9"}
376
+ assert b.get("/", headers=tunnel).status_code == 403 # not shared
377
+ assert b.post("/api/share", json={"host": tunnel["host"]}).status_code == 403 # browsers can't open it
378
+ assert b.post("/api/share", json={"host": "bad host"}, headers=cli).status_code == 400
379
+ path = b.post("/api/share", json={"host": tunnel["host"]}, headers=cli).json()["path"]
380
+
381
+ # the bare tunnel URL and a wrong token get nothing; the link sets the gate cookie
382
+ assert b.get("/", headers=tunnel).status_code == 403
383
+ assert b.get("/share/wrong", headers=tunnel).status_code == 403
384
+ r = b.get(path, headers=tunnel, follow_redirects=False)
385
+ assert r.status_code == 303 and r.headers["location"] == "/" and "secure" in r.headers["set-cookie"].lower()
386
+ gate = {**tunnel, "cookie": f"{server.SHARE_COOKIE}={path.rsplit('/', 1)[1]}"}
387
+ assert b.get("/", headers=gate).status_code == 200
388
+
389
+ # the password is still required, and tunnel visitors are remote
390
+ assert b.get("/api/projects", headers=gate).json() == {"error": "locked"}
391
+ assert b.post("/api/auth/login-link", headers=gate).status_code == 403
392
+ r = b.post("/api/auth/login", json={"password": "longenough"}, headers={**gate, "origin": "https://" + tunnel["host"]})
393
+ assert r.status_code == 200
394
+ login = r.headers["set-cookie"].split(";")[0]
395
+ authed = {**tunnel, "cookie": f"{gate['cookie']}; {login}"}
396
+ assert b.get("/api/projects", headers=authed).status_code == 200
397
+
398
+ # stopping closes the gate and signs out tunnel logins
399
+ assert b.delete("/api/share", headers=cli).status_code == 200
400
+ assert b.get("/", headers=authed).status_code == 403
401
+ auth._cache.clear()
402
+ assert b.get("/api/projects", headers={"cookie": login}).status_code == 401
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes