shelldeck 0.0.2__tar.gz → 0.0.3__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 (50) hide show
  1. {shelldeck-0.0.2 → shelldeck-0.0.3}/CHANGELOG.md +17 -0
  2. {shelldeck-0.0.2 → shelldeck-0.0.3}/PKG-INFO +5 -3
  3. {shelldeck-0.0.2 → shelldeck-0.0.3}/README.md +4 -2
  4. {shelldeck-0.0.2 → shelldeck-0.0.3}/pyproject.toml +1 -1
  5. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/auth.py +11 -2
  6. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/db.py +23 -4
  7. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/server.py +86 -14
  8. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/app.css +45 -0
  9. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/app.js +14 -6
  10. shelldeck-0.0.3/shelldeck/static/devices.js +76 -0
  11. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/index.html +2 -0
  12. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/ui.js +2 -0
  13. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/views.js +2 -1
  14. {shelldeck-0.0.2 → shelldeck-0.0.3}/tests/test_shelldeck.py +41 -0
  15. {shelldeck-0.0.2 → shelldeck-0.0.3}/.gitignore +0 -0
  16. {shelldeck-0.0.2 → shelldeck-0.0.3}/LICENSE +0 -0
  17. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/__init__.py +0 -0
  18. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/__main__.py +0 -0
  19. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/addons/__init__.py +0 -0
  20. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/addons/fast_context.py +0 -0
  21. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/cli.py +0 -0
  22. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/gitgraph.py +0 -0
  23. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/integration/bash.sh +0 -0
  24. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/integration/shelldeck.fish +0 -0
  25. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/integration/shelldeck.ps1 +0 -0
  26. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/integration/zsh/.zshenv +0 -0
  27. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/integration/zsh/.zshrc +0 -0
  28. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/pty.py +0 -0
  29. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/runner.py +0 -0
  30. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/scheduler.py +0 -0
  31. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/shells.py +0 -0
  32. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/gitgraph.js +0 -0
  33. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/history.js +0 -0
  34. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/icon-192.png +0 -0
  35. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/icon-32.png +0 -0
  36. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/icon-512.png +0 -0
  37. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/icon.svg +0 -0
  38. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/manifest.webmanifest +0 -0
  39. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/monitor.js +0 -0
  40. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/vendor/LICENSE-xterm.txt +0 -0
  41. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/vendor/addon-fit.js +0 -0
  42. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/vendor/addon-search.js +0 -0
  43. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/vendor/addon-serialize.js +0 -0
  44. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/vendor/addon-web-links.js +0 -0
  45. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/vendor/addon-webgl.js +0 -0
  46. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/vendor/xterm.css +0 -0
  47. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/static/vendor/xterm.js +0 -0
  48. {shelldeck-0.0.2 → shelldeck-0.0.3}/shelldeck/stats.py +0 -0
  49. {shelldeck-0.0.2 → shelldeck-0.0.3}/tests/test_cli.py +0 -0
  50. {shelldeck-0.0.2 → shelldeck-0.0.3}/tests/test_gitgraph.py +0 -0
@@ -2,6 +2,22 @@
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.3] - 2026-09-29
6
+
7
+ ### Added
8
+
9
+ - Devices page: every signed-in browser with where it came from (this machine, network, or a share), when it signed in, last activity, and whether it is in use; revoke one or sign out all others.
10
+ - One device at a time: other browsers stay signed in but idle; logging in on one takes over and shows the others an "In use on another device" screen.
11
+
12
+ ### Changed
13
+
14
+ - Logging in again in the same browser replaces its old login instead of adding one.
15
+ - Share logins are stored with their share and removed when it stops, is replaced, or the server restarts.
16
+
17
+ ### Fixed
18
+
19
+ - Re-running the one-line installer now upgrades an existing install to the latest release.
20
+
5
21
  ## [0.0.2] - 2026-09-29
6
22
 
7
23
  ### Added
@@ -32,5 +48,6 @@ First public release.
32
48
  - Mandatory password with per-browser logins, idle lock, and HTTPS or SSH-tunnel remote access.
33
49
  - `sd` CLI with a startup banner, `sd search` (LLM-free code search), and one-line installers for Windows and Linux.
34
50
 
51
+ [0.0.3]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.3
35
52
  [0.0.2]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.2
36
53
  [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.2
3
+ Version: 0.0.3
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
@@ -70,7 +70,7 @@ irm https://raw.githubusercontent.com/codejunction/shelldeck/main/install.ps1 |
70
70
  curl -fsSL https://raw.githubusercontent.com/codejunction/shelldeck/main/install.sh | sh
71
71
  ```
72
72
 
73
- The installer sets up [uv](https://docs.astral.sh/uv/) if you don't have it, then installs shelldeck with its own Python 3.12+. Already use Python tooling? Any of these work too:
73
+ The installer sets up [uv](https://docs.astral.sh/uv/) if you don't have it, then installs shelldeck with its own Python 3.12+. Run it again to update to the latest release (or `uv tool upgrade shelldeck`); stop the server first with `sd stop` so Windows can replace `sd.exe`. Already use Python tooling? Any of these work too:
74
74
 
75
75
  ```sh
76
76
  uv tool install shelldeck # or: pipx install shelldeck, or: pip install shelldeck
@@ -130,6 +130,7 @@ sd ~/code/api # add a folder as a project and open a terminal in it
130
130
  ### Secure by default
131
131
 
132
132
  - **Password required.** Every browser logs in separately and locks after 30 minutes idle.
133
+ - **One device at a time.** Any number of browsers can stay signed in, but only one uses shelldeck; logging in on another takes over and idles the rest. The **Devices** page lists every signed-in browser (where from, last active, in use or idle) and revokes any of them.
133
134
  - **Local only.** shelldeck listens on `127.0.0.1` and refuses requests from other websites.
134
135
  - **Remote use** goes over an SSH tunnel or HTTPS. See [Remote access](#remote-access).
135
136
 
@@ -226,7 +227,7 @@ sd share # starts shelldeck if needed, prints a QR code and a link; Ctr
226
227
  - **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
228
  - **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
229
  - **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
+ - **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it (they also vanish from **Devices**; a server restart clears them too). Each `sd share` gets a new address and link.
230
231
 
231
232
  **HTTPS on the VM's address,** with a real certificate (Tailscale `tailscale cert`, Let's Encrypt, your reverse proxy) or a self-signed one:
232
233
 
@@ -242,6 +243,7 @@ sd serve --host 0.0.0.0 --cert cert.pem --key key.pem
242
243
 
243
244
  - **Local only.** The server binds to `127.0.0.1` and rejects HTTP and WebSocket requests whose `Host` or `Origin` isn't the app itself, so other websites can't reach your shells.
244
245
  - **Passwords** are stored as PBKDF2-SHA256 (600k iterations). A login is a random token in an `HttpOnly`, `SameSite=Strict` cookie, and only its SHA-256 is stored. After 5 wrong passwords, each further try waits longer.
246
+ - **Devices.** Each login records where it came from (this machine, the network, or a share). Only one login is in use at a time; the others get `423` until they enter the password again. Revoke any login from the Devices page.
245
247
  - **The CLI** authenticates with a token file in the data folder, readable only by your account and rotated on every server start.
246
248
  - **Forgot the password?** On the host machine, `sd login-link` prints a one-time login URL valid for 5 minutes, and `sd reset-password` removes the password. There is deliberately no way to do either from the browser.
247
249
  - **Your data** lives in `~/.config/shelldeck/` (database, log and saved terminal history). Set `SHELLDECK_HOME` to move it.
@@ -35,7 +35,7 @@ irm https://raw.githubusercontent.com/codejunction/shelldeck/main/install.ps1 |
35
35
  curl -fsSL https://raw.githubusercontent.com/codejunction/shelldeck/main/install.sh | sh
36
36
  ```
37
37
 
38
- The installer sets up [uv](https://docs.astral.sh/uv/) if you don't have it, then installs shelldeck with its own Python 3.12+. Already use Python tooling? Any of these work too:
38
+ The installer sets up [uv](https://docs.astral.sh/uv/) if you don't have it, then installs shelldeck with its own Python 3.12+. Run it again to update to the latest release (or `uv tool upgrade shelldeck`); stop the server first with `sd stop` so Windows can replace `sd.exe`. Already use Python tooling? Any of these work too:
39
39
 
40
40
  ```sh
41
41
  uv tool install shelldeck # or: pipx install shelldeck, or: pip install shelldeck
@@ -95,6 +95,7 @@ sd ~/code/api # add a folder as a project and open a terminal in it
95
95
  ### Secure by default
96
96
 
97
97
  - **Password required.** Every browser logs in separately and locks after 30 minutes idle.
98
+ - **One device at a time.** Any number of browsers can stay signed in, but only one uses shelldeck; logging in on another takes over and idles the rest. The **Devices** page lists every signed-in browser (where from, last active, in use or idle) and revokes any of them.
98
99
  - **Local only.** shelldeck listens on `127.0.0.1` and refuses requests from other websites.
99
100
  - **Remote use** goes over an SSH tunnel or HTTPS. See [Remote access](#remote-access).
100
101
 
@@ -191,7 +192,7 @@ sd share # starts shelldeck if needed, prints a QR code and a link; Ctr
191
192
  - **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
193
  - **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
194
  - **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
+ - **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it (they also vanish from **Devices**; a server restart clears them too). Each `sd share` gets a new address and link.
195
196
 
196
197
  **HTTPS on the VM's address,** with a real certificate (Tailscale `tailscale cert`, Let's Encrypt, your reverse proxy) or a self-signed one:
197
198
 
@@ -207,6 +208,7 @@ sd serve --host 0.0.0.0 --cert cert.pem --key key.pem
207
208
 
208
209
  - **Local only.** The server binds to `127.0.0.1` and rejects HTTP and WebSocket requests whose `Host` or `Origin` isn't the app itself, so other websites can't reach your shells.
209
210
  - **Passwords** are stored as PBKDF2-SHA256 (600k iterations). A login is a random token in an `HttpOnly`, `SameSite=Strict` cookie, and only its SHA-256 is stored. After 5 wrong passwords, each further try waits longer.
211
+ - **Devices.** Each login records where it came from (this machine, the network, or a share). Only one login is in use at a time; the others get `423` until they enter the password again. Revoke any login from the Devices page.
210
212
  - **The CLI** authenticates with a token file in the data folder, readable only by your account and rotated on every server start.
211
213
  - **Forgot the password?** On the host machine, `sd login-link` prints a one-time login URL valid for 5 minutes, and `sd reset-password` removes the password. There is deliberately no way to do either from the browser.
212
214
  - **Your data** lives in `~/.config/shelldeck/` (database, log and saved terminal history). Set `SHELLDECK_HOME` to move it.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "shelldeck"
3
- version = "0.0.2"
3
+ version = "0.0.3"
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"
@@ -141,12 +141,21 @@ def _h(token: str) -> str:
141
141
  return hashlib.sha256(token.encode()).hexdigest()
142
142
 
143
143
 
144
- def new_session(client: str) -> str:
144
+ def new_session(client: str, via: str = "local") -> str:
145
145
  token = secrets.token_urlsafe(32)
146
- db.add_auth_session(_h(token), time.time(), client)
146
+ db.add_auth_session(_h(token), time.time(), client, via)
147
147
  return token
148
148
 
149
149
 
150
+ def alive(h: str | None) -> bool:
151
+ """A session (by hash) that still exists and hasn't idled out."""
152
+ if not h:
153
+ return False
154
+ hit = _cache.get(h)
155
+ row = hit[1] if hit and time.time() - hit[0] < _CACHE_TTL else db.get_auth_session(h)
156
+ return bool(row) and time.time() - row["last_seen"] <= LOCK_TIMEOUT_SECONDS
157
+
158
+
150
159
  def session_hash(token: str | None) -> str | None:
151
160
  """sha256 of a live session token, else None. Idle sessions are removed here."""
152
161
  if not token:
@@ -136,7 +136,8 @@ def init_db() -> None:
136
136
  token_hash TEXT PRIMARY KEY,
137
137
  created_at REAL NOT NULL,
138
138
  last_seen REAL NOT NULL,
139
- client TEXT
139
+ client TEXT,
140
+ via TEXT
140
141
  );
141
142
 
142
143
  -- command history (sessions/projects may be deleted later; keep the text)
@@ -162,6 +163,9 @@ def init_db() -> None:
162
163
  cols = {r[1] for r in conn.execute("PRAGMA table_info(sessions)")}
163
164
  if "name" not in cols:
164
165
  conn.execute("ALTER TABLE sessions ADD COLUMN name TEXT")
166
+ if "via" not in {r[1] for r in conn.execute("PRAGMA table_info(auth_sessions)")}:
167
+ conn.execute("ALTER TABLE auth_sessions ADD COLUMN via TEXT")
168
+ conn.execute("DELETE FROM auth_sessions WHERE via LIKE 'share:%'") # shares end with the server
165
169
  cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)")}
166
170
  if "color" not in cols:
167
171
  conn.execute("ALTER TABLE projects ADD COLUMN color INTEGER")
@@ -317,15 +321,30 @@ def list_sessions_with_project() -> list[dict]:
317
321
  return [dict(r) for r in rows]
318
322
 
319
323
 
320
- def add_auth_session(token_hash: str, now: float, client: str) -> None:
324
+ def add_auth_session(token_hash: str, now: float, client: str, via: str = "local") -> None:
321
325
  with _connect() as conn:
322
326
  conn.execute(
323
- "INSERT INTO auth_sessions (token_hash, created_at, last_seen, client) VALUES (?, ?, ?, ?)",
324
- (token_hash, now, now, client[:200]),
327
+ "INSERT INTO auth_sessions (token_hash, created_at, last_seen, client, via) VALUES (?, ?, ?, ?, ?)",
328
+ (token_hash, now, now, client[:200], via),
325
329
  )
326
330
  conn.commit()
327
331
 
328
332
 
333
+ def list_auth_sessions() -> list[dict]:
334
+ with _connect() as conn:
335
+ conn.row_factory = sqlite3.Row
336
+ return [dict(r) for r in conn.execute("SELECT * FROM auth_sessions ORDER BY last_seen DESC")]
337
+
338
+
339
+ def delete_auth_sessions_via(via: str) -> list[str]:
340
+ """Delete the logins made through `via` (e.g. one share); returns their hashes."""
341
+ with _connect() as conn:
342
+ gone = [r[0] for r in conn.execute("SELECT token_hash FROM auth_sessions WHERE via = ?", (via,))]
343
+ conn.execute("DELETE FROM auth_sessions WHERE via = ?", (via,))
344
+ conn.commit()
345
+ return gone
346
+
347
+
329
348
  def get_auth_session(token_hash: str) -> dict | None:
330
349
  with _connect() as conn:
331
350
  conn.row_factory = sqlite3.Row
@@ -183,6 +183,18 @@ def _who(conn) -> tuple[str, str | None] | None:
183
183
  return ("browser", h) if h else None
184
184
 
185
185
 
186
+ # One device uses shelldeck at a time; the others stay signed in but idle until they log in again.
187
+ _active: dict[str, str | None] = {"h": None}
188
+
189
+
190
+ def _claim(h: str) -> bool:
191
+ """True when browser session `h` may act: it is the active one, or no live session is."""
192
+ if _active["h"] != h and auth.alive(_active["h"]):
193
+ return False
194
+ _active["h"] = h
195
+ return True
196
+
197
+
186
198
  def _denied() -> JSONResponse:
187
199
  return err("locked" if auth.has_password() else "setup_required", 401)
188
200
 
@@ -198,6 +210,8 @@ async def guard(request: Request, call_next):
198
210
  if not who:
199
211
  return _denied()
200
212
  request.state.session = who[1]
213
+ if who[1] and not _claim(who[1]):
214
+ return err("in_use", 423)
201
215
  if who[1] and request.method != "GET": # polling GETs are not activity
202
216
  auth.touch(who[1])
203
217
  return await call_next(request)
@@ -234,11 +248,21 @@ async def shutdown():
234
248
  # ---------------------------------------------------------------------------
235
249
 
236
250
 
237
- def _login_response(request: Request, body: dict, res=None):
238
- """Start a login session: cookie on `res` (JSON body by default)."""
239
- token = auth.new_session(f"{_client(request)} {request.headers.get('user-agent', '')}")
251
+ def _via(request: Request) -> str:
240
252
  if _via_share(request):
241
- _share["sessions"].add(auth.session_hash(token))
253
+ return f"share:{_share['host']}"
254
+ return "local" if _is_local(request) else "network"
255
+
256
+
257
+ async def _login_response(request: Request, body: dict, res=None):
258
+ """Start a login session (cookie on `res`, JSON body by default). It becomes the active device:
259
+ this browser's previous login is replaced and every other device goes idle."""
260
+ if old := auth.session_hash(request.cookies.get(auth.COOKIE)):
261
+ auth.end_session(old)
262
+ token = auth.new_session(f"{_client(request)} {request.headers.get('user-agent', '')}", _via(request))
263
+ h = auth.session_hash(token)
264
+ _active["h"] = h
265
+ await _close_session_sockets(lambda owner: owner != h, IN_USE)
242
266
  res = res or JSONResponse(body)
243
267
  res.set_cookie(
244
268
  auth.COOKIE, token, httponly=True, samesite="strict", path="/",
@@ -263,6 +287,7 @@ async def auth_status(request: Request):
263
287
  "has_password": auth.has_password(),
264
288
  "setup_code_required": not auth.has_password() and not _is_local(request),
265
289
  "authenticated": bool(who),
290
+ "in_use_elsewhere": bool(who and who[1] and who[1] != _active["h"] and auth.alive(_active["h"])),
266
291
  "idle_timeout": auth.LOCK_TIMEOUT_SECONDS,
267
292
  "min_length": auth.MIN_PASSWORD,
268
293
  }
@@ -285,7 +310,7 @@ async def auth_setup(request: Request, payload: dict):
285
310
  return err(problem)
286
311
  auth.set_password(password)
287
312
  log.info("password created from %s", _client(request))
288
- return _login_response(request, {"status": "ok"})
313
+ return await _login_response(request, {"status": "ok"})
289
314
 
290
315
 
291
316
  @app.post("/api/auth/login")
@@ -299,7 +324,7 @@ async def auth_login(request: Request, payload: dict):
299
324
  if not ok:
300
325
  log.warning("failed login from %s", _client(request))
301
326
  return err("invalid_password", 401)
302
- return _login_response(request, {"status": "ok"})
327
+ return await _login_response(request, {"status": "ok"})
303
328
 
304
329
 
305
330
  _login_links: dict[str, float] = {} # one-time code -> expiry
@@ -325,7 +350,7 @@ async def share_start(request: Request, payload: dict):
325
350
  return err("invalid_host")
326
351
  await _share_stop()
327
352
  token = secrets.token_urlsafe(32)
328
- _share.update(host=host, key=hashlib.sha256(token.encode()).hexdigest(), sessions=set())
353
+ _share.update(host=host, key=hashlib.sha256(token.encode()).hexdigest())
329
354
  log.info("sharing via %s", host)
330
355
  return {"path": f"/share/{token}"}
331
356
 
@@ -340,11 +365,11 @@ async def share_stop(request: Request):
340
365
 
341
366
  async def _share_stop() -> None:
342
367
  """Close the gate and sign out every browser that logged in through the tunnel."""
343
- sessions = _share.get("sessions", set())
368
+ if not _share:
369
+ return
370
+ gone = db.delete_auth_sessions_via(f"share:{_share['host']}")
344
371
  _share.clear()
345
- for h in sessions:
346
- auth.end_session(h)
347
- await _close_session_sockets(lambda owner: owner in sessions)
372
+ await _revoke(gone)
348
373
 
349
374
 
350
375
  @app.get("/share/{token}")
@@ -362,7 +387,7 @@ async def auth_use_link(request: Request, code: str):
362
387
  expiry = _login_links.pop(code, 0)
363
388
  if expiry < time.time() or not auth.has_password():
364
389
  return err("link_expired", 403)
365
- return _login_response(request, {}, RedirectResponse("/", status_code=303))
390
+ return await _login_response(request, {}, RedirectResponse("/", status_code=303))
366
391
 
367
392
 
368
393
  @app.post("/api/auth/logout")
@@ -377,6 +402,44 @@ async def auth_logout(request: Request):
377
402
  return res
378
403
 
379
404
 
405
+ @app.get("/api/devices")
406
+ async def devices(request: Request):
407
+ """Browsers signed in: where from, when, and which one is in use."""
408
+ online: dict[str, int] = {}
409
+ for owner in socket_owner.values():
410
+ if owner:
411
+ online[owner] = online.get(owner, 0) + 1
412
+ rows = []
413
+ for r in db.list_auth_sessions():
414
+ h = r["token_hash"]
415
+ if not auth.alive(h):
416
+ continue
417
+ ip, _, agent = (r["client"] or "").partition(" ")
418
+ rows.append({
419
+ "id": h, "ip": ip, "agent": agent, "via": r["via"] or "local",
420
+ "created_at": r["created_at"], "last_seen": r["last_seen"],
421
+ "active": h == _active["h"], "current": h == request.state.session, "sockets": online.get(h, 0),
422
+ })
423
+ return {"devices": rows, "share": _share.get("host")}
424
+
425
+
426
+ @app.delete("/api/devices/{device_id}")
427
+ async def revoke_device(request: Request, device_id: str):
428
+ """Sign one browser out, or with id "others" every browser but this one."""
429
+ if device_id == "others":
430
+ return await _revoke([d["token_hash"] for d in db.list_auth_sessions() if d["token_hash"] != request.state.session])
431
+ return await _revoke([device_id])
432
+
433
+
434
+ async def _revoke(hashes: list[str]) -> dict:
435
+ for h in hashes:
436
+ auth.end_session(h)
437
+ if _active["h"] == h:
438
+ _active["h"] = None
439
+ await _close_session_sockets(lambda owner: owner in hashes)
440
+ return {"revoked": len(hashes)}
441
+
442
+
380
443
  @app.put("/api/auth/password")
381
444
  async def auth_password(request: Request, payload: dict):
382
445
  """Change the password (logged-in browser only). Signs out every other browser."""
@@ -743,11 +806,14 @@ async def _close(ws: WebSocket, code: int = 1000) -> None:
743
806
  pass
744
807
 
745
808
 
746
- async def _close_session_sockets(match) -> None:
809
+ IN_USE = 4423 # socket close code: another device took over
810
+
811
+
812
+ async def _close_session_sockets(match, code: int = 1008) -> None:
747
813
  """Close sockets whose login (session hash; None for the CLI) matches."""
748
814
  for ws, owner in list(socket_owner.items()):
749
815
  if owner and match(owner):
750
- await _close(ws, 1008)
816
+ await _close(ws, code)
751
817
 
752
818
 
753
819
  # ---------------------------------------------------------------------------
@@ -893,6 +959,9 @@ async def alarms_ws(ws: WebSocket):
893
959
  if not who:
894
960
  await ws.close(code=1008)
895
961
  return
962
+ if who[1] and not _claim(who[1]):
963
+ await ws.close(code=IN_USE)
964
+ return
896
965
  await ws.accept()
897
966
  alarm_sockets.add(ws)
898
967
  socket_owner[ws] = who[1]
@@ -914,6 +983,9 @@ async def terminal_ws(ws: WebSocket, session_id: str):
914
983
  if not who:
915
984
  await ws.close(code=1008)
916
985
  return
986
+ if who[1] and not _claim(who[1]):
987
+ await ws.close(code=IN_USE)
988
+ return
917
989
  login = who[1]
918
990
  session = db.get_session(session_id)
919
991
  if not session:
@@ -2322,6 +2322,25 @@ textarea:focus {
2322
2322
  border-radius: 2px;
2323
2323
  }
2324
2324
 
2325
+ /* ------------------------------------------------------------ devices */
2326
+
2327
+ .dev-name {
2328
+ font-weight: 600;
2329
+ }
2330
+
2331
+ .dev-dot {
2332
+ display: inline-block;
2333
+ width: 7px;
2334
+ height: 7px;
2335
+ margin-right: 6px;
2336
+ border-radius: 50%;
2337
+ background: var(--faint);
2338
+ }
2339
+
2340
+ .dev-dot.on {
2341
+ background: var(--ok);
2342
+ }
2343
+
2325
2344
  /* ------------------------------------------------------------ phones */
2326
2345
 
2327
2346
  @media (max-width: 560px) {
@@ -2354,4 +2373,30 @@ textarea:focus {
2354
2373
  .hist-table .hist-actions {
2355
2374
  grid-column: 1 / -1;
2356
2375
  }
2376
+
2377
+ /* devices: name + button, then where from + last active */
2378
+ .dev-table thead,
2379
+ .dev-table td:nth-child(3) {
2380
+ display: none;
2381
+ }
2382
+
2383
+ .dev-table tr {
2384
+ display: grid;
2385
+ grid-template-columns: minmax(0, 1fr) auto;
2386
+ align-items: center;
2387
+ column-gap: 10px;
2388
+ }
2389
+
2390
+ .dev-table td {
2391
+ border: 0 !important;
2392
+ }
2393
+
2394
+ .dev-table tr + tr {
2395
+ border-top: 1px solid var(--border);
2396
+ }
2397
+
2398
+ .dev-table td:nth-child(5) {
2399
+ grid-column: 2;
2400
+ grid-row: 1;
2401
+ }
2357
2402
  }
@@ -444,7 +444,7 @@ class Term {
444
444
  ws.onclose = (ev) => {
445
445
  if (this.closed) return;
446
446
  if (ev.code === 4404 || ev.code === 1000) return this.exited();
447
- if (ev.code === 1008) return bus.dispatchEvent(new Event("locked")); // logging in reloads the page
447
+ if (ev.code === 1008 || ev.code === 4423) return bus.dispatchEvent(new Event("locked")); // logging in reloads the page
448
448
  this.status("Reconnecting…");
449
449
  const delay = Math.min(10000, 500 * 2 ** this.retry++);
450
450
  clearTimeout(this.retryTimer);
@@ -1452,12 +1452,12 @@ function toggleSidebar() {
1452
1452
 
1453
1453
  // ------------------------------------------------------------- top bar/views
1454
1454
 
1455
- const VIEW_TITLES = { bookmarks: "Bookmarks", scheduler: "Scheduler", tasks: "Tasks", monitor: "Task manager", history: "Command history" };
1455
+ const VIEW_TITLES = { bookmarks: "Bookmarks", scheduler: "Scheduler", tasks: "Tasks", monitor: "Task manager", history: "Command history", devices: "Devices" };
1456
1456
 
1457
1457
  export function switchView(view) {
1458
1458
  if (S.view === view) return;
1459
1459
  S.view = view;
1460
- for (const v of ["terminals", "bookmarks", "scheduler", "tasks", "monitor", "history"]) $(`#view-${v}`).hidden = v !== view;
1460
+ for (const v of ["terminals", "bookmarks", "scheduler", "tasks", "monitor", "history", "devices"]) $(`#view-${v}`).hidden = v !== view;
1461
1461
  for (const b of $$(".sb-link[data-view]")) b.classList.toggle("active", b.dataset.view === view && view !== "terminals");
1462
1462
  $("#term-actions").hidden = view !== "terminals";
1463
1463
  if (mobile.matches) $("#app").classList.remove("sb-mobile-open");
@@ -1603,6 +1603,7 @@ function commandPalette() {
1603
1603
  ["Search all terminals", "search", () => searchAllDialog(), "Ctrl+Shift+F"],
1604
1604
  ["Command history", "history", () => switchView("history"), "Ctrl+Alt+R"],
1605
1605
  ["Task manager", "activity", () => switchView("monitor"), "Ctrl+Alt+M"],
1606
+ ["Devices", "devices", () => switchView("devices")],
1606
1607
  ["Open terminals", "terminal", () => switchView("terminals")],
1607
1608
  ["Open bookmarks", "bookmark", () => switchView("bookmarks")],
1608
1609
  ["Open scheduler", "clock", () => switchView("scheduler"), "Ctrl+Alt+S"],
@@ -1718,7 +1719,7 @@ let authMode = null; // "login" | "setup" while the lock screen is up
1718
1719
  /** Lock screen: log in, or create the first password. */
1719
1720
  async function showAuth(status = null) {
1720
1721
  status ||= await api("/api/auth/status").catch(() => ({ has_password: true }));
1721
- const mode = status.has_password ? "login" : "setup";
1722
+ const mode = !status.has_password ? "setup" : status.in_use_elsewhere ? "inuse" : "login";
1722
1723
  if (!$("#lock").hidden && authMode === mode) return;
1723
1724
  authMode = mode;
1724
1725
  const form = $("#lock-form");
@@ -1733,6 +1734,13 @@ async function showAuth(status = null) {
1733
1734
  <p class="faint small">You are connecting from another machine. The setup code is printed where shelldeck runs, and saved in its data folder as <code>setup-code</code>.</p>` : ""}
1734
1735
  <div class="error" id="lock-error"></div>
1735
1736
  <button class="btn primary" type="submit">Create password</button>`
1737
+ : mode === "inuse"
1738
+ ? `<img src="/static/icon.svg" alt="" width="40" height="40" />
1739
+ <h2>In use on another device</h2>
1740
+ <p class="muted">shelldeck is used on one device at a time. Enter the password to use it here; the other device goes idle.</p>
1741
+ <input type="password" name="password" placeholder="Password" autocomplete="current-password" required />
1742
+ <div class="error" id="lock-error"></div>
1743
+ <button class="btn primary" type="submit">Use here</button>`
1736
1744
  : `<img src="/static/icon.svg" alt="" width="40" height="40" />
1737
1745
  <h2>shelldeck is locked</h2>
1738
1746
  <input type="password" name="password" placeholder="Password" autocomplete="current-password" required />
@@ -1775,7 +1783,7 @@ function connectAlarms() {
1775
1783
  if (msg.type === "alarm_snapshot") msg.alarms.forEach((t) => views.showAlarm(t, false));
1776
1784
  };
1777
1785
  ws.onclose = (ev) => {
1778
- if (ev.code !== 1008) setTimeout(connectAlarms, 3000);
1786
+ if (ev.code !== 1008 && ev.code !== 4423) setTimeout(connectAlarms, 3000);
1779
1787
  };
1780
1788
  }
1781
1789
 
@@ -1868,7 +1876,7 @@ async function init() {
1868
1876
  wireLock();
1869
1877
  views.init();
1870
1878
  const status = await api("/api/auth/status").catch(() => null);
1871
- if (!status?.authenticated) {
1879
+ if (!status?.authenticated || status.in_use_elsewhere) {
1872
1880
  await finishBoot();
1873
1881
  return showAuth(status);
1874
1882
  }
@@ -0,0 +1,76 @@
1
+ // Devices page: every signed-in browser, which one is in use, and revoke.
2
+ import { api, confirmDialog, esc, icon, toast, toastError } from "./ui.js";
3
+
4
+ function ago(ts) {
5
+ const s = Math.max(0, Date.now() / 1000 - ts);
6
+ if (s < 60) return "just now";
7
+ if (s < 3600) return `${Math.floor(s / 60)} min ago`;
8
+ if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
9
+ return new Date(ts * 1000).toLocaleDateString();
10
+ }
11
+
12
+ /** "Edge on Windows" from a user agent; good enough to tell your own devices apart. */
13
+ function deviceName(ua) {
14
+ const browser = /Edg\//.test(ua) ? "Edge" : /OPR\//.test(ua) ? "Opera" : /Firefox\//.test(ua) ? "Firefox"
15
+ : /Chrome\//.test(ua) ? "Chrome" : /Safari\//.test(ua) ? "Safari" : "Browser";
16
+ const os = /iPhone|iPad/.test(ua) ? "iOS" : /Android/.test(ua) ? "Android" : /Windows/.test(ua) ? "Windows"
17
+ : /Mac OS X/.test(ua) ? "macOS" : /Linux/.test(ua) ? "Linux" : "";
18
+ return os ? `${browser} on ${os}` : browser;
19
+ }
20
+
21
+ function viaLabel(via) {
22
+ if (via.startsWith("share:")) return `Share <span class="faint">${esc(via.slice(6))}</span>`;
23
+ return via === "network" ? "Network" : "This machine";
24
+ }
25
+
26
+ export async function renderDevices(el) {
27
+ if (!el.dataset.wired) {
28
+ el.dataset.wired = "1";
29
+ el.innerHTML = `<div class="page-head">
30
+ <div><h1>Devices</h1><p>Browsers signed in to shelldeck. One device is in use at a time; logging in on another makes it the one in use and idles the rest until they log in again. Stopping a share signs out its devices.</p></div>
31
+ <button class="btn" data-dev="others">${icon("power")}Sign out other devices</button>
32
+ </div>
33
+ <p class="dev-summary muted"></p>
34
+ <div class="card mon-table dev-table"><table><thead><tr><th>Device</th><th>From</th><th>Signed in</th><th>Last active</th><th></th></tr></thead><tbody></tbody></table></div>`;
35
+ el.addEventListener("click", (e) => onClick(e, el));
36
+ }
37
+ let data;
38
+ try {
39
+ data = await api("/api/devices");
40
+ } catch (e) {
41
+ return toastError(e);
42
+ }
43
+ const n = data.devices.length;
44
+ el.querySelector(".dev-summary").textContent =
45
+ `${n} signed in · ${data.devices.filter((d) => d.active).length} in use` + (data.share ? ` · sharing via ${data.share}` : "");
46
+ el.querySelector("[data-dev=others]").disabled = n < 2;
47
+ el.querySelector("tbody").innerHTML = data.devices
48
+ .map(
49
+ (d) => `<tr>
50
+ <td><div class="dev-name">${esc(deviceName(d.agent))}${d.current ? ' <span class="exit ok">this device</span>' : ""}</div>
51
+ <div class="hist-meta">${d.active ? '<span class="dev-dot on"></span>In use' : '<span class="dev-dot"></span>Idle'}${d.sockets ? ` · ${d.sockets} connection${d.sockets > 1 ? "s" : ""}` : ""}</div></td>
52
+ <td>${viaLabel(d.via)}<div class="hist-meta">${esc(d.ip)}</div></td>
53
+ <td class="nowrap faint">${esc(ago(d.created_at))}</td>
54
+ <td class="nowrap faint">${esc(ago(d.last_seen))}</td>
55
+ <td class="nowrap"><button class="btn sm" data-dev="revoke" data-id="${esc(d.id)}" data-me="${d.current ? 1 : ""}">${d.current ? "Sign out" : "Revoke"}</button></td>
56
+ </tr>`,
57
+ )
58
+ .join("");
59
+ }
60
+
61
+ async function onClick(e, el) {
62
+ const b = e.target.closest("[data-dev]");
63
+ if (!b) return;
64
+ const me = b.dataset.me === "1";
65
+ const others = b.dataset.dev === "others";
66
+ const what = others ? "Sign out every other device?" : me ? "Sign out this device?" : "Revoke this device?";
67
+ if (!(await confirmDialog(`${what} It will need the password to come back.`, { ok: "Sign out", danger: true }))) return;
68
+ try {
69
+ const r = await api(`/api/devices/${others ? "others" : encodeURIComponent(b.dataset.id)}`, { method: "DELETE" });
70
+ if (me) return location.reload();
71
+ toast(`${r.revoked} device${r.revoked === 1 ? "" : "s"} signed out`);
72
+ } catch (err) {
73
+ return toastError(err);
74
+ }
75
+ renderDevices(el);
76
+ }
@@ -47,6 +47,7 @@
47
47
  <button class="sb-link" data-action="view" data-view="tasks"><i data-icon="kanban"></i>Tasks</button>
48
48
  <button class="sb-link" data-action="view" data-view="history"><i data-icon="history"></i>History</button>
49
49
  <button class="sb-link" data-action="view" data-view="monitor"><i data-icon="activity"></i>Task manager</button>
50
+ <button class="sb-link" data-action="view" data-view="devices"><i data-icon="devices"></i>Devices</button>
50
51
  <div class="sb-row">
51
52
  <button class="sb-link" data-action="settings"><i data-icon="settings"></i>Settings</button>
52
53
  <span id="conn" class="conn" title="Server connection"></span>
@@ -88,6 +89,7 @@
88
89
  <section id="view-tasks" class="view page" hidden></section>
89
90
  <section id="view-monitor" class="view page" hidden></section>
90
91
  <section id="view-history" class="view page" hidden></section>
92
+ <section id="view-devices" class="view page" hidden></section>
91
93
  <div id="keybar" aria-label="Terminal keys">
92
94
  <button data-key="esc">Esc</button><button data-key="tab">Tab</button><button data-key="ctrl" aria-pressed="false">Ctrl</button>
93
95
  <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>
@@ -22,6 +22,7 @@ export async function api(path, { method = "GET", body } = {}) {
22
22
  /* empty body */
23
23
  }
24
24
  if (res.status === 401 && (data?.error === "locked" || data?.error === "setup_required")) bus.dispatchEvent(new Event("locked"));
25
+ if (res.status === 423) bus.dispatchEvent(new Event("locked")); // another device is in use
25
26
  if (!res.ok) {
26
27
  const e = new Error(data?.error || `HTTP ${res.status}`);
27
28
  e.data = data;
@@ -98,6 +99,7 @@ const P = {
98
99
  external: '<path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
99
100
  pencil: '<path d="M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
100
101
  trash: '<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
102
+ devices: '<rect x="2" y="4" width="14" height="10" rx="1.5"/><path d="M5 18h8"/><rect x="17" y="8" width="5" height="12" rx="1"/>',
101
103
  lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
102
104
  up: '<path d="M12 19V5M5 12l7-7 7 7"/>',
103
105
  clipboard: '<rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>',
@@ -1,6 +1,7 @@
1
1
  import { S, TERMINAL_THEMES, applySettings, findSession, newTerminal, orderedProjects, palette, refreshProjects, shellLabel } from "./app.js";
2
2
  import { renderMonitor } from "./monitor.js";
3
3
  import { renderHistory } from "./history.js";
4
+ import { renderDevices } from "./devices.js";
4
5
  import { $, $$, api, authError, confirmDialog, dialog, esc, fmtTime, fromLocalInput, hydrateIcons, icon, toLocalInput, toast, toastError, withEyes } from "./ui.js";
5
6
 
6
7
  let bookmarks = [];
@@ -12,7 +13,7 @@ export function init() {}
12
13
  export function show(view, el) {
13
14
  current = view;
14
15
  clearInterval(pollTimer);
15
- const render = { bookmarks: renderBookmarks, scheduler: renderScheduler, tasks: renderTasks, monitor: renderMonitor, history: renderHistory }[view];
16
+ const render = { bookmarks: renderBookmarks, scheduler: renderScheduler, tasks: renderTasks, monitor: renderMonitor, history: renderHistory, devices: renderDevices }[view];
16
17
  render(el);
17
18
  if (!["bookmarks", "monitor", "history"].includes(view)) { // monitor refreshes from its own 2s poller
18
19
  pollTimer = setInterval(() => {
@@ -23,6 +23,7 @@ def client(tmp_path, monkeypatch):
23
23
  def browser(tmp_path, monkeypatch):
24
24
  """A client with no CLI token: it must log in like a browser."""
25
25
  auth._failures.clear()
26
+ monkeypatch.setitem(server._active, "h", None)
26
27
  monkeypatch.setenv("SHELLDECK_HOME", str(tmp_path))
27
28
  monkeypatch.setattr(server, "ALLOWED_HOSTS", {"testserver"})
28
29
  with TestClient(server.app) as c:
@@ -394,9 +395,49 @@ def test_share_gate_needs_link_then_password(browser):
394
395
  login = r.headers["set-cookie"].split(";")[0]
395
396
  authed = {**tunnel, "cookie": f"{gate['cookie']}; {login}"}
396
397
  assert b.get("/api/projects", headers=authed).status_code == 200
398
+ vias = {d["via"] for d in b.get("/api/devices", headers=authed).json()["devices"]}
399
+ assert vias == {"local", "share:" + tunnel["host"]}
397
400
 
398
401
  # stopping closes the gate and signs out tunnel logins
399
402
  assert b.delete("/api/share", headers=cli).status_code == 200
400
403
  assert b.get("/", headers=authed).status_code == 403
401
404
  auth._cache.clear()
402
405
  assert b.get("/api/projects", headers={"cookie": login}).status_code == 401
406
+
407
+
408
+ def test_devices_one_active_takeover_and_revoke(browser, tmp_path):
409
+ a = browser
410
+ a.post("/api/auth/setup", json={"password": "longenough", "confirm": "longenough"})
411
+ sid = a.post("/api/sessions", json={"cwd": str(tmp_path)}).json()["id"]
412
+ c = TestClient(server.app)
413
+ assert c.post("/api/auth/login", json={"password": "longenough"}).status_code == 200
414
+
415
+ # c logged in last, so c is in use and a is idle (API 423, sockets closed with 4423)
416
+ assert c.get("/api/projects").status_code == 200
417
+ assert a.get("/api/projects").status_code == 423
418
+ assert a.get("/api/auth/status").json()["in_use_elsewhere"] is True
419
+ with pytest.raises(WebSocketDisconnect) as ex, a.websocket_connect(f"/ws/{sid}"):
420
+ pass
421
+ assert ex.value.code == server.IN_USE
422
+ devs = c.get("/api/devices").json()["devices"]
423
+ assert len(devs) == 2 and [d["current"] for d in devs if d["active"]] == [True]
424
+
425
+ # a takes over with the password; its old login is replaced, not duplicated
426
+ assert a.post("/api/auth/login", json={"password": "longenough"}).status_code == 200
427
+ assert a.get("/api/projects").status_code == 200 and c.get("/api/projects").status_code == 423
428
+ assert len(a.get("/api/devices").json()["devices"]) == 2
429
+
430
+ # a revokes c; c is signed out
431
+ other = next(d["id"] for d in a.get("/api/devices").json()["devices"] if not d["current"])
432
+ assert a.delete(f"/api/devices/{other}").json() == {"revoked": 1}
433
+ assert c.get("/api/projects").status_code == 401
434
+ assert [d["current"] for d in a.get("/api/devices").json()["devices"]] == [True]
435
+
436
+ # "others" keeps this browser; once the active one logs out, an idle one may act again
437
+ c.post("/api/auth/login", json={"password": "longenough"})
438
+ assert c.delete("/api/devices/others").json() == {"revoked": 1}
439
+ assert a.get("/api/projects").status_code == 401
440
+ d = TestClient(server.app)
441
+ d.post("/api/auth/login", json={"password": "longenough"})
442
+ d.post("/api/auth/logout")
443
+ assert c.get("/api/projects").status_code == 200
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes