pycodex-ws 0.4.1__tar.gz → 0.4.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 (41) hide show
  1. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/PKG-INFO +2 -2
  2. pycodex_ws-0.4.3/pycodex_router/browser.py +107 -0
  3. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pycodex_router/client.py +10 -4
  4. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pycodex_router/index.html +170 -33
  5. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pycodex_router/protocol.py +27 -0
  6. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pycodex_router/server.py +202 -25
  7. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pyproject.toml +2 -2
  8. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/app.py +39 -1
  9. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/workspace.html +69 -4
  10. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/workspaces.html +87 -20
  11. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/workspaces.py +16 -0
  12. pycodex_ws-0.4.1/pycodex_router/browser.py +0 -175
  13. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/.gitignore +0 -0
  14. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/LICENSE +0 -0
  15. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/README.md +0 -0
  16. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pycodex_router/__init__.py +0 -0
  17. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pycodex_router/__main__.py +0 -0
  18. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pycodex_ws/__init__.py +0 -0
  19. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pycodex_ws/feishu_card.py +0 -0
  20. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pycodex_ws/feishu_link.py +0 -0
  21. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/pycodex_ws/feishu_oauth.py +0 -0
  22. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/__init__.py +0 -0
  23. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/__main__.py +0 -0
  24. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/content_views.LICENSE.txt +0 -0
  25. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/content_views.css +0 -0
  26. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/content_views.mjs +0 -0
  27. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/editor.css +0 -0
  28. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/editor.mjs +0 -0
  29. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/.gitignore +0 -0
  30. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/build.mjs +0 -0
  31. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/composer.mjs +0 -0
  32. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/content_views.css +0 -0
  33. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/content_views.mjs +0 -0
  34. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/editor.css +0 -0
  35. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/editor.mjs +0 -0
  36. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/editor_context.mjs +0 -0
  37. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/package-lock.json +0 -0
  38. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/package.json +0 -0
  39. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/frontend/view_labels.mjs +0 -0
  40. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/owned_process.py +0 -0
  41. {pycodex_ws-0.4.1 → pycodex_ws-0.4.3}/workspace_server/workspace_views.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pycodex-ws
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: Browser workspace and optional Feishu integration for python-codex
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.7
7
7
  Requires-Dist: pillow>=9.0.0
8
- Requires-Dist: python-codex==0.4.1
8
+ Requires-Dist: python-codex==0.4.3
9
9
  Requires-Dist: websockets<16,>=11
10
10
  Description-Content-Type: text/markdown
11
11
 
@@ -0,0 +1,107 @@
1
+ """Browser access checks for trusted devices sharing a router origin."""
2
+
3
+ import ipaddress
4
+ import re
5
+ from urllib.parse import urlencode, urlsplit
6
+
7
+ from starlette.exceptions import HTTPException
8
+ from starlette.requests import HTTPConnection
9
+ from starlette.responses import JSONResponse, RedirectResponse
10
+
11
+ from .protocol import route_path
12
+
13
+
14
+ def request_origin(connection):
15
+ try:
16
+ parsed = urlsplit("//" + connection.headers["host"])
17
+ port = parsed.port
18
+ except ValueError:
19
+ raise HTTPException(400, "Invalid router host.")
20
+ host = parsed.hostname
21
+ if (
22
+ not host
23
+ or parsed.username is not None
24
+ or parsed.password is not None
25
+ or parsed.path
26
+ or parsed.query
27
+ or parsed.fragment
28
+ ):
29
+ raise HTTPException(400, "Invalid router host.")
30
+ try:
31
+ ipaddress.ip_address(host)
32
+ except ValueError:
33
+ if len(host) > 253 or any(
34
+ not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label)
35
+ for label in host.split(".")
36
+ ):
37
+ raise HTTPException(400, "Invalid router host.")
38
+ scheme = "https" if connection.scope["scheme"] in {"https", "wss"} else "http"
39
+ suffix = "" if port in (None, 443 if scheme == "https" else 80) else ":" + str(port)
40
+ authority = "[" + host + "]" if ":" in host else host
41
+ return scheme + "://" + authority + suffix
42
+
43
+
44
+ class BrowserAccess:
45
+ def __init__(self, router, app):
46
+ self.router = router
47
+ self.app = app
48
+
49
+ async def __call__(self, scope, receive, send):
50
+ connection = HTTPConnection(scope)
51
+ origin = request_origin(connection)
52
+
53
+ async def secured_send(message):
54
+ if message["type"] == "http.response.start":
55
+ message = dict(
56
+ message,
57
+ headers=list(message["headers"])
58
+ + [
59
+ (b"content-security-policy", b"frame-ancestors 'self'"),
60
+ (b"x-frame-options", b"SAMEORIGIN"),
61
+ (b"x-content-type-options", b"nosniff"),
62
+ (b"referrer-policy", b"no-referrer"),
63
+ (b"permissions-policy", b"document-domain=()"),
64
+ (b"origin-agent-cluster", b"?1"),
65
+ ],
66
+ )
67
+ await send(message)
68
+
69
+ try:
70
+ if connection.client.host in self.router.blocked_ips:
71
+ raise HTTPException(
72
+ 403,
73
+ "Too many incorrect passwords. This IP is blocked "
74
+ "until the router restarts.",
75
+ )
76
+ if connection.headers.get("origin") not in (None, origin):
77
+ raise HTTPException(
78
+ 403, "Cross-origin router requests are not allowed."
79
+ )
80
+ await self.app(scope, receive, secured_send)
81
+ except HTTPException as exc:
82
+ if scope["type"] == "websocket":
83
+ await send({"type": "websocket.close", "code": 1008})
84
+ else:
85
+ await JSONResponse({"error": exc.detail}, exc.status_code)(
86
+ scope, receive, secured_send
87
+ )
88
+
89
+
90
+ async def require_login(scope, receive, send):
91
+ connection = HTTPConnection(scope)
92
+ if scope["type"] == "websocket":
93
+ await send({"type": "websocket.close", "code": 1008})
94
+ elif "/api/" in route_path(scope) or scope["method"] not in {"GET", "HEAD"}:
95
+ await JSONResponse({"error": "authentication required"}, 401)(
96
+ scope, receive, send
97
+ )
98
+ else:
99
+ node_id = scope["path_params"]["node_id"]
100
+ prefix = scope.get("root_path", "")
101
+ root = prefix[: -len("/n/" + node_id)]
102
+ target = prefix + route_path(scope)
103
+ if connection.url.query:
104
+ target += "?" + connection.url.query
105
+ await RedirectResponse(
106
+ root + "/login?" + urlencode({"next": target}), 303
107
+ )(scope, receive, send)
@@ -4,9 +4,10 @@ import secrets
4
4
  import socket
5
5
  from contextlib import asynccontextmanager
6
6
 
7
- from websockets.exceptions import ConnectionClosed, InvalidHandshake
7
+ from websockets.exceptions import ConnectionClosed, InvalidHandshake, InvalidStatusCode
8
8
  from websockets.legacy.client import connect
9
- from .protocol import decode, device_id, encode, send_event, tunnel_url
9
+
10
+ from .protocol import decode, device_id, encode, parse_target, send_event, tunnel_url
10
11
 
11
12
 
12
13
  class NodeIdInUse(Exception):
@@ -25,8 +26,9 @@ class RouterClient:
25
26
  reconnect_seconds=1.0,
26
27
  ):
27
28
  self.app = app
28
- self.control_url = tunnel_url(target, "control")
29
- self.data_url = tunnel_url(target, "data")
29
+ address, self._registration_key = parse_target(target)
30
+ self.control_url = tunnel_url(address, "control")
31
+ self.data_url = tunnel_url(address, "data")
30
32
  self._identity_key = secrets.token_hex(32)
31
33
  self.node_id = device_id(self._identity_key)
32
34
  self.name = name
@@ -43,6 +45,7 @@ class RouterClient:
43
45
  def _connect(self, url):
44
46
  return connect(
45
47
  url,
48
+ extra_headers={"Authorization": "Bearer " + self._registration_key},
46
49
  open_timeout=10,
47
50
  close_timeout=2,
48
51
  ping_interval=20,
@@ -101,6 +104,8 @@ class RouterClient:
101
104
  )
102
105
  if isinstance(failure, ConnectionClosed) and failure.rcvd is not None:
103
106
  reason += ", close_code={0}".format(failure.rcvd.code)
107
+ elif isinstance(failure, InvalidStatusCode):
108
+ reason += ", status_code={0}".format(failure.status_code)
104
109
  self._logger.warning(
105
110
  "Router node %s disconnected (%s); retrying in %g seconds",
106
111
  self.name,
@@ -230,6 +235,7 @@ def attach_router(app, registry, target, port):
230
235
  "enable_board": workspace["enable_board"],
231
236
  "session_count": len(workspace["sessions"]),
232
237
  "running": any(session["running"] for session in workspace["sessions"]),
238
+ "unread": any(session["unread"] for session in workspace["sessions"]),
233
239
  }
234
240
  for workspace in registry.list_workspaces()
235
241
  ]
@@ -36,9 +36,9 @@
36
36
  * { box-sizing: border-box; }
37
37
  body { margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink); font-size: 13px; }
38
38
  a { color: inherit; text-decoration: none; }
39
- a:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; border-radius: 3px; }
39
+ a:focus-visible, .device-label:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; border-radius: 3px; }
40
40
  svg { width: 20px; height: 20px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
41
- h1, h2, h3, p { margin: 0; }
41
+ h1, h2, h3, h4, p { margin: 0; }
42
42
  code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
43
43
  .page { width: min(1440px, calc(100% - 48px)); margin: 0 auto; padding-bottom: 32px; }
44
44
  .topbar { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 0; border-bottom: 1px solid var(--line); }
@@ -55,36 +55,58 @@
55
55
  .intro { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 18px; padding: 24px 0; }
56
56
  h1 { font-size: 24px; line-height: 1.3; font-weight: 650; letter-spacing: -.7px; }
57
57
  .intro p { color: var(--muted); font-size: 12px; line-height: 1.6; }
58
+ .registration { max-width: 640px; margin-bottom: 24px; }
59
+ .registration summary { width: fit-content; color: var(--accent); cursor: pointer; }
60
+ .registration-content { display: grid; gap: 8px; margin-top: 12px; }
61
+ .registration input { width: 100%; min-width: 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--ink); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
62
+ .registration p { color: var(--muted); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
63
+ #alias-status { margin-bottom: 12px; color: var(--danger); font-size: 12px; }
64
+ #alias-status:empty { display: none; }
58
65
  .layout { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 20px; align-items: start; }
59
66
  .layout > * { min-width: 0; }
60
67
  .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
61
68
  h2 { display: flex; align-items: center; gap: 9px; font-size: 13px; font-weight: 600; }
62
69
  .count { min-width: 23px; padding: 2px 7px; border-radius: 6px; background: var(--soft); color: var(--muted); font-size: 11px; text-align: center; font-variant-numeric: tabular-nums; }
63
70
  #activity { color: var(--green); font-size: 12px; }
71
+ #workspaces { display: grid; gap: 16px; }
64
72
  .workspace-table, .nodes { background: var(--panel); border: 1px solid var(--line); border-radius: 9px; }
65
- .list-head, .workspace-row { display: grid; grid-template-columns: minmax(94px, .85fr) minmax(120px, 1.35fr) minmax(110px, 1fr) 60px 44px; gap: 12px; align-items: center; padding: 10px 14px; }
66
- .list-head { position: sticky; top: 0; z-index: 1; border-radius: 9px 9px 0 0; background: var(--soft); color: var(--muted); font-size: 10px; font-weight: 550; letter-spacing: .3px; }
67
- .workspace-row { min-height: 62px; border-top: 1px solid var(--line); }
73
+ .workspace-group-heading { display: flex; align-items: center; gap: 10px; padding: 12px 14px; }
74
+ .workspace-group-name { flex: 1; min-width: 0; font-size: 12px; font-weight: 600; line-height: 1.6; overflow-wrap: anywhere; }
75
+ .workspace-group-heading .count { flex: 0 0 auto; }
76
+ .list-head, .workspace-row { display: grid; grid-template-columns: minmax(94px, .85fr) minmax(120px, 1.85fr) 76px 44px; gap: 12px; align-items: center; padding: 10px 14px; }
77
+ .list-head { position: sticky; top: 0; z-index: 1; background: var(--soft); color: var(--muted); font-size: 10px; font-weight: 550; letter-spacing: .3px; }
78
+ .workspace-row { position: relative; min-height: 62px; border-top: 1px solid var(--line); }
68
79
  .workspace-row > * { min-width: 0; }
69
80
  .workspace-row:hover { background: var(--soft); }
70
81
  .workspace-row:last-child { border-radius: 0 0 9px 9px; }
71
- .activity-badge { justify-self: start; display: inline-flex; align-items: center; gap: 5px; padding: 3px 6px; border-radius: 4px; color: var(--muted); background: var(--soft); font-size: 10px; font-weight: 500; }
82
+ .activity-badge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 6px; border-radius: 4px; color: var(--muted); background: var(--soft); font-size: 10px; font-weight: 500; }
72
83
  .activity-badge[data-state="working"] { color: var(--green); background: var(--green-soft); }
73
- .activity-badge[data-state="working"]::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
84
+ .activity-badge[data-state="working"]::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; animation: spinner-pulse 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite; }
85
+ @keyframes spinner-pulse {
86
+ 0%, 100% { opacity: .45; transform: scale(.82); }
87
+ 50% { opacity: 1; transform: scale(1.08); }
88
+ }
89
+ @media (prefers-reduced-motion: reduce) {
90
+ .activity-badge[data-state="working"]::before { animation: none; opacity: .8; transform: none; }
91
+ }
92
+ .workspace-status { display: inline-flex; align-items: center; gap: 6px; justify-self: start; }
93
+ .unread-dot { flex: 0 0 6px; width: 6px; height: 6px; border-radius: 50%; background: var(--danger); }
74
94
  .workspace-title { font-size: 12px; line-height: 1.6; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
75
95
  .workspace-path, .state-path { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; line-height: 1.8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
76
96
  .state-path { color: var(--muted); }
77
- .workspace-device { color: var(--muted); }
78
- .workspace-device code { display: block; min-width: 0; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
79
97
  .session-count { color: var(--muted); font-size: 11px; line-height: 1.7; }
80
98
  .open-link, .manage-link { display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px; min-height: 28px; color: var(--accent); font-size: 11px; font-weight: 550; white-space: nowrap; }
81
99
  .open-link svg, .manage-link svg { width: 13px; height: 13px; }
100
+ .open-link::after { content: ""; position: absolute; inset: 0; }
82
101
  .open-link:hover, .manage-link:hover { text-decoration: underline; text-underline-offset: 4px; }
83
102
  .device { min-width: 0; padding: 12px 14px; }
84
103
  .device + .device { border-top: 1px solid var(--line); }
85
104
  .device-heading { display: flex; align-items: start; gap: 9px; }
86
105
  .device-heading svg { width: 17px; height: 17px; margin-top: 1px; color: var(--muted); }
87
106
  .device-name { flex: 1; min-width: 0; font-size: 11px; font-weight: 500; line-height: 1.6; overflow-wrap: anywhere; }
107
+ .device-label { display: block; width: 100%; padding: 0; border: 0; background: none; color: inherit; font: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-align: left; overflow-wrap: anywhere; cursor: text; }
108
+ .device-label[hidden] { display: none; }
109
+ .device-alias-input { width: 100%; min-width: 0; padding: 3px 6px; border: 1px solid var(--accent); border-radius: 4px; background: var(--panel); color: var(--ink); font: inherit; outline: none; }
88
110
  .online-dot { margin-top: 7px; }
89
111
  .device-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 2px 8px; margin-top: 6px; }
90
112
  .device-detail { color: var(--muted); font-size: 10px; line-height: 1.5; }
@@ -104,10 +126,8 @@
104
126
  .intro { padding: 20px 0; }
105
127
  .list-head { display: none; }
106
128
  .workspace-row { grid-template-columns: minmax(0, 1fr) auto auto; gap: 6px 10px; padding: 10px 12px; }
107
- .workspace-row:first-child { border-top: 0; border-radius: 9px 9px 0 0; }
108
129
  .workspace-paths { grid-column: 1 / -1; grid-row: 2; }
109
- .workspace-device { grid-column: 1 / -1; grid-row: 3; }
110
- .activity-badge { grid-column: 2; grid-row: 1; }
130
+ .workspace-status { grid-column: 2; grid-row: 1; }
111
131
  .open-link { grid-column: 3; grid-row: 1; min-height: 36px; }
112
132
  }
113
133
  </style>
@@ -130,19 +150,24 @@
130
150
  <h1>Your workspaces</h1>
131
151
  <p>Across your connected devices.</p>
132
152
  </div>
153
+ <details id="device-connection" class="registration">
154
+ <summary>Connect a trusted device</summary>
155
+ <div class="registration-content">
156
+ <label for="registration-key">Registration key</label>
157
+ <input id="registration-key" readonly autocomplete="off" spellcheck="false">
158
+ <p id="registration-status" role="status"></p>
159
+ <p>On the device, use <code>pycodex-ws --listen-to KEY@ROUTER_ADDRESS:6000</code>. Replace <code>KEY</code> with this key and use your router's device port.</p>
160
+ </div>
161
+ </details>
162
+ <p id="alias-status" role="alert"></p>
133
163
  <div class="layout">
134
164
  <section aria-labelledby="workspace-heading">
135
165
  <div class="section-heading">
136
166
  <h2 id="workspace-heading">Workspaces <span id="workspace-count" class="count">—</span></h2>
137
167
  <span id="activity"></span>
138
168
  </div>
139
- <div class="workspace-table">
140
- <div class="list-head" aria-hidden="true">
141
- <span>Workspace</span><span>Directory / state</span><span>Device</span><span>Status</span><span></span>
142
- </div>
143
- <div id="workspaces" aria-busy="true">
144
- <div class="empty"><h3>Loading workspaces…</h3></div>
145
- </div>
169
+ <div id="workspaces" aria-busy="true">
170
+ <div class="empty"><h3>Loading workspaces…</h3></div>
146
171
  </div>
147
172
  </section>
148
173
  <aside aria-labelledby="device-heading">
@@ -154,20 +179,32 @@
154
179
  </div>
155
180
  </main>
156
181
  </div>
182
+ <template id="workspace-group-template">
183
+ <section class="workspace-table workspace-group">
184
+ <header class="workspace-group-heading">
185
+ <h3 class="workspace-group-name"><button type="button" class="device-label"></button></h3>
186
+ <span class="count"></span>
187
+ </header>
188
+ <div class="list-head" aria-hidden="true">
189
+ <span>Workspace</span><span>Directory / state</span><span>Status</span><span></span>
190
+ </div>
191
+ <div class="workspace-group-list"></div>
192
+ </section>
193
+ </template>
157
194
  <template id="workspace-template">
158
195
  <article class="workspace-row">
159
196
  <div class="workspace-main">
160
- <h3 class="workspace-title"></h3>
197
+ <h4 class="workspace-title"></h4>
161
198
  <span class="session-count"></span>
162
199
  </div>
163
200
  <div class="workspace-paths">
164
201
  <p class="workspace-path"></p>
165
202
  <p class="state-path"></p>
166
203
  </div>
167
- <div class="workspace-device">
168
- <code></code>
169
- </div>
170
- <span class="activity-badge"></span>
204
+ <span class="workspace-status">
205
+ <span class="unread-dot" role="img" aria-label="Unread reply" title="Unread reply"></span>
206
+ <span class="activity-badge"></span>
207
+ </span>
171
208
  <a class="open-link">Open <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14m-6-6 6 6-6 6"/></svg></a>
172
209
  </article>
173
210
  </template>
@@ -175,7 +212,7 @@
175
212
  <article class="device">
176
213
  <div class="device-heading">
177
214
  <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8m-4-4v4"/></svg>
178
- <h3 class="device-name"><code></code></h3>
215
+ <h3 class="device-name"><button type="button" class="device-label"></button></h3>
179
216
  <span class="online-dot" aria-label="Online"></span>
180
217
  </div>
181
218
  <div class="device-footer">
@@ -198,11 +235,36 @@
198
235
  const layout = document.querySelector(".layout");
199
236
  const workspaces = document.getElementById("workspaces");
200
237
  const nodes = document.getElementById("nodes");
238
+ const workspaceGroupTemplate = document.getElementById("workspace-group-template");
201
239
  const workspaceTemplate = document.getElementById("workspace-template");
202
240
  const deviceTemplate = document.getElementById("device-template");
203
241
  const emptyTemplate = document.getElementById("empty-template");
242
+ const deviceConnection = document.getElementById("device-connection");
243
+ const registrationKey = document.getElementById("registration-key");
244
+ const registrationStatus = document.getElementById("registration-status");
245
+ const aliasStatus = document.getElementById("alias-status");
204
246
  let refreshing = false;
205
247
  let signature = "";
248
+ let editingDevice = null;
249
+ let aliasRevision = 0;
250
+
251
+ registrationKey.addEventListener("focus", () => registrationKey.select());
252
+ deviceConnection.addEventListener("toggle", async () => {
253
+ registrationKey.value = "";
254
+ registrationStatus.textContent = "";
255
+ if (!deviceConnection.open) return;
256
+ registrationStatus.textContent = "Loading…";
257
+ try {
258
+ const response = await fetch("api/registration-key", {cache: "no-store"});
259
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
260
+ const data = await response.json();
261
+ if (!deviceConnection.open) return;
262
+ registrationKey.value = data.key;
263
+ registrationStatus.textContent = "Select the key to copy it.";
264
+ } catch (error) {
265
+ if (deviceConnection.open) registrationStatus.textContent = `Could not load key · ${error.message}`;
266
+ }
267
+ });
206
268
 
207
269
  function countLabel(count, label) {
208
270
  return `${count} ${label}${count === 1 ? "" : "s"}`;
@@ -221,30 +283,104 @@
221
283
  layout.dataset.stale = String(state === "error");
222
284
  }
223
285
 
286
+ function bindDeviceLabel(label, node, data) {
287
+ label.textContent = node.alias || node.name;
288
+ label.title = `${node.name}\nClick to rename device`;
289
+ label.setAttribute("aria-label", `Rename device ${node.alias || node.name}`);
290
+ label.addEventListener("click", () => editDeviceAlias(label, node, data));
291
+ }
292
+
293
+ function editDeviceAlias(label, node, data) {
294
+ if (editingDevice !== null) return;
295
+ editingDevice = node.id;
296
+ aliasStatus.textContent = "";
297
+ const input = document.createElement("input");
298
+ input.type = "text";
299
+ input.className = "device-alias-input";
300
+ input.maxLength = 128;
301
+ input.setAttribute("aria-label", "Device alias");
302
+ input.title = "Leave empty to use the original device name.";
303
+ input.value = node.alias || "";
304
+ input.placeholder = node.name;
305
+ label.hidden = true;
306
+ label.before(input);
307
+ let finished = false;
308
+ const finish = async save => {
309
+ if (finished) return;
310
+ finished = true;
311
+ const alias = input.value.trim();
312
+ input.remove();
313
+ label.hidden = false;
314
+ try {
315
+ if (save && alias !== (node.alias || "")) {
316
+ const response = await fetch(`api/nodes/${encodeURIComponent(node.id)}/alias`, {
317
+ method: "POST",
318
+ headers: {"Content-Type": "application/json"},
319
+ body: JSON.stringify({alias}),
320
+ });
321
+ if (!response.ok) throw new Error(await response.text());
322
+ const result = await response.json();
323
+ node.alias = result.alias;
324
+ aliasRevision += 1;
325
+ render(data);
326
+ signature = JSON.stringify(data);
327
+ }
328
+ } catch (error) {
329
+ aliasStatus.textContent = `Could not rename device: ${error.message}`;
330
+ } finally {
331
+ editingDevice = null;
332
+ refresh();
333
+ }
334
+ };
335
+ input.addEventListener("keydown", event => {
336
+ if (event.isComposing) return;
337
+ if (event.key === "Enter" || event.key === "Escape") {
338
+ event.preventDefault();
339
+ finish(event.key === "Enter");
340
+ label.focus();
341
+ }
342
+ });
343
+ input.addEventListener("blur", () => finish(true));
344
+ input.focus();
345
+ input.select();
346
+ }
347
+
224
348
  function render(data) {
225
349
  const workspaceList = document.createDocumentFragment();
350
+ const groups = new Map();
351
+ for (const node of data.nodes) {
352
+ const group = workspaceGroupTemplate.content.firstElementChild.cloneNode(true);
353
+ group.setAttribute("aria-label", node.alias || node.name);
354
+ bindDeviceLabel(group.querySelector(".device-label"), node, data);
355
+ groups.set(node.id, {node, group});
356
+ }
226
357
  for (const workspace of data.workspaces) {
227
358
  const card = workspaceTemplate.content.firstElementChild.cloneNode(true);
228
359
  const title = card.querySelector(".workspace-title");
229
360
  title.textContent = workspace.id;
230
361
  title.title = workspace.id;
362
+ card.querySelector(".unread-dot").hidden = !workspace.unread;
231
363
  const path = card.querySelector(".workspace-path");
232
364
  path.textContent = workspace.work_dir;
233
365
  path.title = workspace.work_dir;
234
366
  const statePath = card.querySelector(".state-path");
235
367
  statePath.textContent = workspace.state_path;
236
368
  statePath.title = workspace.state_path;
237
- const device = card.querySelector(".workspace-device code");
238
- device.textContent = workspace.node_name;
239
- device.title = workspace.node_name;
240
369
  const activity = card.querySelector(".activity-badge");
241
370
  activity.dataset.state = workspace.running ? "working" : "ready";
242
371
  activity.textContent = workspace.running ? "Working" : "Ready";
243
372
  card.querySelector(".session-count").textContent = countLabel(workspace.session_count, "session");
244
373
  const link = card.querySelector(".open-link");
245
374
  link.href = workspace.url;
246
- link.setAttribute("aria-label", `Open ${workspace.id} on ${workspace.node_name}`);
247
- workspaceList.append(card);
375
+ const {node, group} = groups.get(workspace.node_id);
376
+ link.setAttribute("aria-label", `Open ${workspace.id} on ${node.alias || node.name}`);
377
+ group.querySelector(".workspace-group-list").append(card);
378
+ }
379
+ for (const {group} of groups.values()) {
380
+ const count = group.querySelector(".workspace-group-list").childElementCount;
381
+ if (!count) continue;
382
+ group.querySelector(".count").textContent = countLabel(count, "workspace");
383
+ workspaceList.append(group);
248
384
  }
249
385
  if (!data.workspaces.length) {
250
386
  workspaceList.append(empty("No workspaces yet", data.nodes.length
@@ -255,13 +391,13 @@
255
391
  const nodeList = document.createDocumentFragment();
256
392
  for (const node of data.nodes) {
257
393
  const card = deviceTemplate.content.firstElementChild.cloneNode(true);
258
- card.querySelector(".device-name code").textContent = node.name;
394
+ bindDeviceLabel(card.querySelector(".device-label"), node, data);
259
395
  const owned = data.workspaces.filter(workspace => workspace.node_id === node.id);
260
396
  const sessions = owned.reduce((total, workspace) => total + workspace.session_count, 0);
261
397
  card.querySelector(".device-detail").textContent = `${countLabel(owned.length, "workspace")} · ${countLabel(sessions, "session")}`;
262
398
  const link = card.querySelector(".manage-link");
263
399
  link.href = node.url;
264
- link.setAttribute("aria-label", `Manage workspaces on ${node.name}`);
400
+ link.setAttribute("aria-label", `Manage workspaces on ${node.alias || node.name}`);
265
401
  nodeList.append(card);
266
402
  }
267
403
  if (!data.nodes.length) {
@@ -278,6 +414,7 @@
278
414
  async function refresh() {
279
415
  if (refreshing) return;
280
416
  refreshing = true;
417
+ const revision = aliasRevision;
281
418
  try {
282
419
  const response = await fetch("api/workspaces", {cache: "no-store"});
283
420
  if (response.status === 401) {
@@ -287,7 +424,7 @@
287
424
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
288
425
  const data = await response.json();
289
426
  const next = JSON.stringify(data);
290
- if (next !== signature) {
427
+ if (editingDevice === null && revision === aliasRevision && next !== signature) {
291
428
  render(data);
292
429
  signature = next;
293
430
  }
@@ -48,6 +48,33 @@ def tunnel_url(target, endpoint):
48
48
  )
49
49
 
50
50
 
51
+ def parse_target(target):
52
+ """Separate a registration key from the address used on the wire."""
53
+ try:
54
+ has_scheme = "://" in target
55
+ parsed = urlsplit(target if has_scheme else "ws://" + target)
56
+ key = parsed.username
57
+ if (
58
+ not key
59
+ or not re.fullmatch("[1-9A-HJ-NP-Za-km-z]{1,22}", key)
60
+ or parsed.password is not None
61
+ ):
62
+ raise ValueError("invalid registration key")
63
+ authority = parsed.netloc.rsplit("@", 1)[1]
64
+ if not has_scheme and parsed.port is None:
65
+ authority += ":6000"
66
+ address = urlunsplit(
67
+ (parsed.scheme, authority, parsed.path, parsed.query, parsed.fragment)
68
+ )
69
+ tunnel_url(address, "control")
70
+ except ValueError:
71
+ raise ValueError(
72
+ "router address must be KEY@host[:port] or ws(s)://KEY@host[:port], "
73
+ "using the router-generated registration key"
74
+ ) from None
75
+ return address, key
76
+
77
+
51
78
  def encode(value):
52
79
  def binary(item):
53
80
  if isinstance(item, bytes):