clay-server 2.5.0

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 (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +281 -0
  3. package/bin/cli.js +2385 -0
  4. package/lib/cli-sessions.js +270 -0
  5. package/lib/config.js +237 -0
  6. package/lib/daemon.js +489 -0
  7. package/lib/ipc.js +112 -0
  8. package/lib/notes.js +120 -0
  9. package/lib/pages.js +664 -0
  10. package/lib/project.js +1433 -0
  11. package/lib/public/app.js +2795 -0
  12. package/lib/public/apple-touch-icon-dark.png +0 -0
  13. package/lib/public/apple-touch-icon.png +0 -0
  14. package/lib/public/css/base.css +264 -0
  15. package/lib/public/css/diff.css +128 -0
  16. package/lib/public/css/filebrowser.css +1114 -0
  17. package/lib/public/css/highlight.css +144 -0
  18. package/lib/public/css/icon-strip.css +296 -0
  19. package/lib/public/css/input.css +573 -0
  20. package/lib/public/css/menus.css +856 -0
  21. package/lib/public/css/messages.css +1445 -0
  22. package/lib/public/css/mobile-nav.css +354 -0
  23. package/lib/public/css/overlays.css +697 -0
  24. package/lib/public/css/rewind.css +505 -0
  25. package/lib/public/css/server-settings.css +761 -0
  26. package/lib/public/css/sidebar.css +936 -0
  27. package/lib/public/css/sticky-notes.css +358 -0
  28. package/lib/public/css/title-bar.css +314 -0
  29. package/lib/public/favicon-dark.svg +1 -0
  30. package/lib/public/favicon.svg +1 -0
  31. package/lib/public/icon-192-dark.png +0 -0
  32. package/lib/public/icon-192.png +0 -0
  33. package/lib/public/icon-512-dark.png +0 -0
  34. package/lib/public/icon-512.png +0 -0
  35. package/lib/public/icon-mono.svg +1 -0
  36. package/lib/public/index.html +762 -0
  37. package/lib/public/manifest.json +27 -0
  38. package/lib/public/modules/diff.js +398 -0
  39. package/lib/public/modules/events.js +21 -0
  40. package/lib/public/modules/filebrowser.js +1411 -0
  41. package/lib/public/modules/fileicons.js +172 -0
  42. package/lib/public/modules/icons.js +54 -0
  43. package/lib/public/modules/input.js +584 -0
  44. package/lib/public/modules/markdown.js +356 -0
  45. package/lib/public/modules/notifications.js +649 -0
  46. package/lib/public/modules/qrcode.js +70 -0
  47. package/lib/public/modules/rewind.js +345 -0
  48. package/lib/public/modules/server-settings.js +510 -0
  49. package/lib/public/modules/sidebar.js +1083 -0
  50. package/lib/public/modules/state.js +3 -0
  51. package/lib/public/modules/sticky-notes.js +688 -0
  52. package/lib/public/modules/terminal.js +697 -0
  53. package/lib/public/modules/theme.js +738 -0
  54. package/lib/public/modules/tools.js +1608 -0
  55. package/lib/public/modules/utils.js +56 -0
  56. package/lib/public/style.css +15 -0
  57. package/lib/public/sw.js +75 -0
  58. package/lib/push.js +124 -0
  59. package/lib/sdk-bridge.js +989 -0
  60. package/lib/server.js +582 -0
  61. package/lib/sessions.js +424 -0
  62. package/lib/terminal-manager.js +187 -0
  63. package/lib/terminal.js +24 -0
  64. package/lib/themes/ayu-light.json +9 -0
  65. package/lib/themes/catppuccin-latte.json +9 -0
  66. package/lib/themes/catppuccin-mocha.json +9 -0
  67. package/lib/themes/clay-light.json +10 -0
  68. package/lib/themes/clay.json +10 -0
  69. package/lib/themes/dracula.json +9 -0
  70. package/lib/themes/everforest-light.json +9 -0
  71. package/lib/themes/everforest.json +9 -0
  72. package/lib/themes/github-light.json +9 -0
  73. package/lib/themes/gruvbox-dark.json +9 -0
  74. package/lib/themes/gruvbox-light.json +9 -0
  75. package/lib/themes/monokai.json +9 -0
  76. package/lib/themes/nord-light.json +9 -0
  77. package/lib/themes/nord.json +9 -0
  78. package/lib/themes/one-dark.json +9 -0
  79. package/lib/themes/one-light.json +9 -0
  80. package/lib/themes/rose-pine-dawn.json +9 -0
  81. package/lib/themes/rose-pine.json +9 -0
  82. package/lib/themes/solarized-dark.json +9 -0
  83. package/lib/themes/solarized-light.json +9 -0
  84. package/lib/themes/tokyo-night-light.json +9 -0
  85. package/lib/themes/tokyo-night.json +9 -0
  86. package/lib/updater.js +97 -0
  87. package/package.json +47 -0
package/lib/pages.js ADDED
@@ -0,0 +1,664 @@
1
+ function pinPageHtml() {
2
+ return '<!DOCTYPE html><html lang="en"><head>' +
3
+ '<meta charset="UTF-8">' +
4
+ '<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">' +
5
+ '<meta name="apple-mobile-web-app-capable" content="yes">' +
6
+ '<title>Clay</title>' +
7
+ '<style>' +
8
+ '*{margin:0;padding:0;box-sizing:border-box}' +
9
+ 'body{background:#2F2E2B;color:#E8E5DE;font-family:system-ui,-apple-system,sans-serif;' +
10
+ 'min-height:100dvh;display:flex;align-items:center;justify-content:center;padding:20px}' +
11
+ '.c{max-width:320px;width:100%;text-align:center}' +
12
+ 'h1{color:#DA7756;font-size:22px;margin-bottom:8px}' +
13
+ '.sub{color:#908B81;font-size:14px;margin-bottom:32px}' +
14
+ 'input{width:100%;background:#393733;border:1px solid #3E3C37;border-radius:12px;' +
15
+ 'color:#E8E5DE;font-size:24px;letter-spacing:12px;text-align:center;padding:14px;' +
16
+ 'outline:none;font-family:inherit;-webkit-text-security:disc}' +
17
+ 'input:focus{border-color:#DA7756}' +
18
+ 'input::placeholder{letter-spacing:0;font-size:15px;color:#6D6860}' +
19
+ '.err{color:#E5534B;font-size:13px;margin-top:12px;min-height:1.3em}' +
20
+ '</style></head><body><div class="c">' +
21
+ '<h1>Clay</h1>' +
22
+ '<div class="sub">Enter PIN to continue</div>' +
23
+ '<input id="pin" type="tel" maxlength="6" placeholder="6-digit PIN" autocomplete="off" inputmode="numeric">' +
24
+ '<div class="err" id="err"></div>' +
25
+ '<script>' +
26
+ 'var inp=document.getElementById("pin"),err=document.getElementById("err");' +
27
+ 'inp.focus();' +
28
+ 'inp.addEventListener("input",function(){' +
29
+ 'if(inp.value.length===6){' +
30
+ 'fetch("/auth",{method:"POST",headers:{"Content-Type":"application/json"},' +
31
+ 'body:JSON.stringify({pin:inp.value})})' +
32
+ '.then(function(r){return r.json()})' +
33
+ '.then(function(d){' +
34
+ 'if(d.ok){location.reload();return}' +
35
+ 'if(d.locked){inp.disabled=true;err.textContent="Too many attempts. Try again in "+Math.ceil(d.retryAfter/60)+" min";' +
36
+ 'setTimeout(function(){inp.disabled=false;err.textContent="";inp.focus()},d.retryAfter*1000);return}' +
37
+ 'var msg="Wrong PIN";if(typeof d.attemptsLeft==="number"&&d.attemptsLeft<=3)msg+=" ("+d.attemptsLeft+" left)";' +
38
+ 'err.textContent=msg;inp.value="";inp.focus()})' +
39
+ '.catch(function(){err.textContent="Connection error"})}});' +
40
+ '</script></div></body></html>';
41
+ }
42
+
43
+ function setupPageHtml(httpsUrl, httpUrl, hasCert, lanMode) {
44
+ return `<!DOCTYPE html><html lang="en"><head>
45
+ <meta charset="UTF-8">
46
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
47
+ <meta name="apple-mobile-web-app-capable" content="yes">
48
+ <link rel="manifest" href="/manifest.json">
49
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png">
50
+ <title>Setup - Clay</title>
51
+ <style>
52
+ *{margin:0;padding:0;box-sizing:border-box}
53
+ body{background:#2F2E2B;color:#E8E5DE;font-family:system-ui,-apple-system,sans-serif;min-height:100dvh;display:flex;justify-content:center;padding:env(safe-area-inset-top,0) 20px 40px}
54
+ .c{max-width:480px;width:100%;padding-top:40px}
55
+ h1{color:#DA7756;font-size:22px;margin:0 0 4px;text-align:center}
56
+ .subtitle{text-align:center;color:#908B81;font-size:13px;margin-bottom:28px}
57
+
58
+ /* Steps indicator */
59
+ .steps-bar{display:flex;gap:6px;margin-bottom:32px}
60
+ .steps-bar .pip{flex:1;height:3px;border-radius:2px;background:#3E3C37;transition:background 0.3s}
61
+ .steps-bar .pip.done{background:#57AB5A}
62
+ .steps-bar .pip.active{background:#DA7756}
63
+
64
+ /* Step card */
65
+ .step-card{display:none;animation:fadeIn 0.25s ease}
66
+ .step-card.active{display:block}
67
+ @keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
68
+
69
+ .step-label{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#DA7756;font-weight:600;margin-bottom:8px}
70
+ .step-title{font-size:18px;font-weight:600;margin-bottom:6px}
71
+ .step-desc{font-size:14px;line-height:1.6;color:#908B81;margin-bottom:20px}
72
+
73
+ .instruction{display:flex;gap:12px;margin-bottom:16px}
74
+ .inst-num{width:24px;height:24px;border-radius:50%;background:rgba(218,119,86,0.15);color:#DA7756;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;flex-shrink:0;margin-top:1px}
75
+ .inst-text{font-size:14px;line-height:1.6}
76
+ .inst-text .note{font-size:12px;color:#6D6860;margin-top:4px}
77
+
78
+ .btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;background:#DA7756;color:#fff;text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;font-size:14px;margin:4px 0;border:none;cursor:pointer;font-family:inherit;transition:opacity 0.15s}
79
+ .btn:hover{opacity:0.9}
80
+ .btn.outline{background:transparent;border:1.5px solid #3E3C37;color:#E8E5DE}
81
+ .btn.outline:hover{border-color:#6D6860}
82
+ .btn.success{background:#57AB5A}
83
+ .btn:disabled{opacity:0.4;cursor:default}
84
+
85
+ .btn-row{display:flex;gap:8px;margin-top:20px}
86
+ .btn-row .btn{flex:1}
87
+
88
+ .check-status{display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:10px;font-size:13px;margin:16px 0}
89
+ .check-status.ok{background:rgba(87,171,90,0.1);color:#57AB5A;border:1px solid rgba(87,171,90,0.15)}
90
+ .check-status.warn{background:rgba(218,119,86,0.06);border:1px solid rgba(218,119,86,0.15);color:#DA7756}
91
+ .check-status.pending{background:rgba(144,139,129,0.06);border:1px solid rgba(144,139,129,0.15);color:#908B81}
92
+
93
+ .platform-ios,.platform-android,.platform-desktop{display:none}
94
+
95
+ .done-card{text-align:center;padding:40px 0}
96
+ .done-icon{font-size:48px;margin-bottom:16px}
97
+ .done-title{font-size:20px;font-weight:600;margin-bottom:8px}
98
+ .done-desc{font-size:14px;color:#908B81;margin-bottom:24px}
99
+
100
+ .skip-link{display:block;text-align:center;color:#6D6860;font-size:13px;text-decoration:none;margin-top:12px;cursor:pointer;border:none;background:none;font-family:inherit}
101
+ .skip-link:hover{color:#908B81}
102
+ </style></head><body>
103
+ <div class="c">
104
+ <h1>Clay</h1>
105
+ <p class="subtitle">Setup your device for the best experience</p>
106
+
107
+ <div class="steps-bar" id="steps-bar"></div>
108
+
109
+ <!-- Step: Tailscale -->
110
+ <div class="step-card" id="step-tailscale">
111
+ <div class="step-label">Step <span class="step-cur">1</span> of <span class="step-total">4</span></div>
112
+ <div class="step-title">Connect via Tailscale</div>
113
+ <div class="step-desc">Tailscale creates a private VPN so you can access Clay from anywhere. It needs to be installed on <b>both</b> the server (the machine running Clay) and this device.</div>
114
+
115
+ <div class="instruction"><div class="inst-num">1</div>
116
+ <div class="inst-text"><b>Server:</b> Install Tailscale on the machine running Clay.
117
+ <div class="note">If you are viewing this page, the server likely already has Tailscale. You can verify by checking its 100.x.x.x IP.</div>
118
+ </div>
119
+ </div>
120
+
121
+ <div class="instruction"><div class="inst-num">2</div>
122
+ <div class="inst-text"><b>This device:</b> Install Tailscale here and sign in with the same account.
123
+ <div class="platform-ios" style="margin-top:8px">
124
+ <a class="btn" href="https://apps.apple.com/app/tailscale/id1470499037" target="_blank" rel="noopener">App Store</a>
125
+ </div>
126
+ <div class="platform-android" style="margin-top:8px">
127
+ <a class="btn" href="https://play.google.com/store/apps/details?id=com.tailscale.ipn" target="_blank" rel="noopener">Google Play</a>
128
+ </div>
129
+ <div class="platform-desktop" style="margin-top:8px">
130
+ <a class="btn" href="https://tailscale.com/download" target="_blank" rel="noopener">Download Tailscale</a>
131
+ </div>
132
+ </div>
133
+ </div>
134
+
135
+ <div class="instruction"><div class="inst-num">3</div>
136
+ <div class="inst-text">Once both devices are on Tailscale, open the relay using the server's Tailscale IP.
137
+ <div class="note" id="tailscale-url-hint"></div>
138
+ </div>
139
+ </div>
140
+
141
+ <div id="ts-status" class="check-status pending">Checking connection...</div>
142
+ <div class="btn-row">
143
+ <button class="btn" id="ts-next" onclick="nextStep()" disabled>Verifying...</button>
144
+ </div>
145
+ </div>
146
+
147
+ <!-- Step: Certificate -->
148
+ <div class="step-card" id="step-cert">
149
+ <div class="step-label">Step <span class="step-cur">1</span> of <span class="step-total">3</span></div>
150
+ <div class="step-title">Install certificate</div>
151
+ <div class="step-desc">Encrypt all traffic between this device and the relay. The certificate is generated locally and does not grant any additional access.</div>
152
+
153
+ <div class="instruction"><div class="inst-num">1</div>
154
+ <div class="inst-text">Download the certificate.<br>
155
+ <a class="btn" href="/ca/download" style="margin-top:8px">Download Certificate</a>
156
+ </div>
157
+ </div>
158
+
159
+ <div class="platform-ios">
160
+ <div class="instruction"><div class="inst-num">2</div>
161
+ <div class="inst-text">Open <b>Settings</b> and tap the <b>Profile Downloaded</b> banner to install.
162
+ <div class="note">If the banner is gone: Settings > General > VPN & Device Management</div>
163
+ </div>
164
+ </div>
165
+ <div class="instruction"><div class="inst-num">3</div>
166
+ <div class="inst-text">Go to <b>Settings > General > About > Certificate Trust Settings</b> and enable full trust.</div>
167
+ </div>
168
+ </div>
169
+
170
+ <div class="platform-android">
171
+ <div class="instruction"><div class="inst-num">2</div>
172
+ <div class="inst-text">Open the downloaded file, or go to <b>Settings > Security > Install a certificate > CA certificate</b>.
173
+ <div class="note">Path may vary by device. Search "certificate" in Settings if needed.</div>
174
+ </div>
175
+ </div>
176
+ </div>
177
+
178
+ <div class="platform-desktop">
179
+ <div class="instruction"><div class="inst-num">2</div>
180
+ <div class="inst-text">The certificate should be trusted automatically via mkcert. If your browser still shows a warning, run <code>mkcert -install</code> on the host machine.</div>
181
+ </div>
182
+ </div>
183
+
184
+ <div id="cert-status" class="check-status pending">Checking HTTPS connection...</div>
185
+ <div class="btn-row">
186
+ <button class="btn" id="cert-retry" onclick="checkHttps()" style="display:none">Retry</button>
187
+ <button class="btn" id="cert-next" onclick="nextStep()" disabled>Verifying...</button>
188
+ </div>
189
+ </div>
190
+
191
+ <!-- Step: Install PWA -->
192
+ <div class="step-card" id="step-pwa">
193
+ <div class="step-label">Step <span class="step-cur">2</span> of <span class="step-total">3</span></div>
194
+ <div class="step-title">Add to Home Screen</div>
195
+ <div class="step-desc">Install Clay as an app for quick access and a full-screen experience.</div>
196
+
197
+ <div class="platform-ios">
198
+ <div class="check-status warn">On iOS, push notifications only work from the installed app. This step is required.</div>
199
+ <div id="ios-not-safari" class="check-status warn" style="display:none">You must use <b>Safari</b> to install. Open this page in Safari first.</div>
200
+ <div id="ios-safari-steps">
201
+ <div class="instruction"><div class="inst-num">1</div>
202
+ <div class="inst-text">Tap the <b>Share</b> button <svg width="18" height="18" viewBox="0 0 17.695 26.475" style="vertical-align:middle;margin:0 2px"><g fill="currentColor"><path d="M17.334 10.762v9.746c0 2.012-1.025 3.027-3.066 3.027H3.066C1.026 23.535 0 22.52 0 20.508v-9.746C0 8.75 1.025 7.734 3.066 7.734h2.94v1.573h-2.92c-.977 0-1.514.527-1.514 1.543v9.57c0 1.015.537 1.543 1.514 1.543h11.152c.967 0 1.524-.527 1.524-1.543v-9.57c0-1.016-.557-1.543-1.524-1.543h-2.91V7.734h2.94c2.04 0 3.066 1.016 3.066 3.028Z"/><path d="M8.662 15.889c.42 0 .781-.352.781-.762V5.097l-.058-1.464.654.693 1.484 1.582a.698.698 0 0 0 .528.235c.4 0 .713-.293.713-.694 0-.205-.088-.361-.235-.508l-3.3-3.183c-.196-.196-.362-.264-.567-.264-.195 0-.361.069-.566.264L4.795 4.94a.681.681 0 0 0-.225.508c0 .4.293.694.703.694.186 0 .4-.079.538-.235l1.474-1.582.664-.693-.058 1.465v10.029c0 .41.351.762.771.762Z"/></g></svg> at the bottom of the Safari toolbar.
203
+ <div class="note" id="ios-ipad-hint" style="display:none">On iPad, the Share button is in the top toolbar.</div>
204
+ </div>
205
+ </div>
206
+ <div class="instruction"><div class="inst-num">2</div>
207
+ <div class="inst-text">Scroll down in the share sheet and tap <b>Add to Home Screen</b> <svg width="18" height="18" viewBox="0 0 25 25" style="vertical-align:middle;margin:0 2px"><g fill="currentColor"><path d="m23.40492,1.60784c-1.32504,-1.32504 -3.19052,-1.56912 -5.59644,-1.56912l-10.65243,0c-2.33622,0 -4.2017,0.24408 -5.5267,1.56912c-1.32504,1.34243 -1.56911,3.17306 -1.56911,5.50924l0,10.5827c0,2.40596 0.22665,4.254 1.55165,5.57902c1.34246,1.32501 3.19052,1.5691 5.59647,1.5691l10.60013,0c2.40592,0 4.2714,-0.24408 5.59644,-1.5691c1.325,-1.34245 1.55166,-3.17306 1.55166,-5.57902l0,-10.51293c0,-2.40596 -0.22666,-4.25401 -1.55166,-5.57901zm-0.38355,5.21289l0,11.24518c0,1.51681 -0.20924,2.94643 -1.02865,3.78327c-0.83683,0.83685 -2.30134,1.0635 -3.81815,1.0635l-11.33234,0c-1.51681,0 -2.96386,-0.22665 -3.80073,-1.0635c-0.83683,-0.83684 -1.04607,-2.26646 -1.04607,-3.78327l0,-11.19288c0,-1.5517 0.20924,-3.01617 1.02865,-3.85304c0.83687,-0.83683 2.31876,-1.04607 3.87042,-1.04607l11.28007,0c1.51681,0 2.98132,0.22666 3.81815,1.06353c0.81941,0.81941 1.02865,2.26645 1.02865,3.78327zm-10.53039,12.08205c0.64506,0 1.02861,-0.43586 1.02861,-1.13326l0,-4.34117l4.53294,0c0.66252,0 1.13326,-0.36613 1.13326,-0.99376c0,-0.64506 -0.43586,-1.02861 -1.13326,-1.02861l-4.53294,0l0,-4.53294c0,-0.6974 -0.38355,-1.13326 -1.02861,-1.13326c-0.62763,0 -0.99376,0.45332 -0.99376,1.13326l0,4.53294l-4.51552,0c-0.69737,0 -1.15069,0.38355 -1.15069,1.02861c0,0.62763 0.48817,0.99376 1.15069,0.99376l4.51552,0l0,4.34117c0,0.66252 0.36613,1.13326 0.99376,1.13326z"/></g></svg></div>
208
+ </div>
209
+ <div class="instruction"><div class="inst-num">3</div>
210
+ <div class="inst-text">Tap <b>Add</b> in the top right corner to confirm.</div>
211
+ </div>
212
+ </div>
213
+ </div>
214
+
215
+ <div class="platform-android">
216
+ <div class="instruction"><div class="inst-num">1</div>
217
+ <div class="inst-text">Tap the <b>three dots menu</b> <svg width="16" height="16" viewBox="0 0 24 24" style="vertical-align:middle;margin:0 2px"><circle cx="12" cy="4" r="2.5" fill="currentColor"/><circle cx="12" cy="12" r="2.5" fill="currentColor"/><circle cx="12" cy="20" r="2.5" fill="currentColor"/></svg> in the top right corner of Chrome.</div>
218
+ </div>
219
+ <div class="instruction"><div class="inst-num">2</div>
220
+ <div class="inst-text">Tap <b>Install app</b> or <b>Add to Home screen</b>.
221
+ <div class="note">If you don't see it, try <b>Open in Chrome</b> first if using another browser.</div>
222
+ </div>
223
+ </div>
224
+ <div class="instruction"><div class="inst-num">3</div>
225
+ <div class="inst-text">Tap <b>Install</b> in the confirmation dialog.</div>
226
+ </div>
227
+ </div>
228
+
229
+ <div class="platform-desktop">
230
+ <div class="instruction"><div class="inst-num">1</div>
231
+ <div class="inst-text">Look for the <b>install icon</b> in the address bar (a monitor with a down arrow).</div>
232
+ </div>
233
+ <div class="instruction"><div class="inst-num">2</div>
234
+ <div class="inst-text">Click it and then click <b>Install</b> to confirm.
235
+ <div class="note">If there is no icon, go to <b>Menu > Install Clay</b> or <b>Menu > Save and Share > Install</b>.</div>
236
+ </div>
237
+ </div>
238
+ </div>
239
+
240
+ <div id="pwa-status" class="check-status pending">After installing, open Clay from your home screen to continue setup.</div>
241
+ <button class="skip-link" id="pwa-skip" onclick="nextStep()" style="display:none">Skip for now</button>
242
+ </div>
243
+
244
+ <!-- Step 3: Push Notifications -->
245
+ <div class="step-card" id="step-push">
246
+ <div class="step-label">Step <span class="step-cur">3</span> of <span class="step-total">3</span></div>
247
+ <div class="step-title">Enable notifications</div>
248
+ <div class="step-desc">Get alerted on your phone when Claude finishes a response, even when the app is in the background.</div>
249
+
250
+ <div id="push-needs-https" class="check-status warn" style="display:none">Push notifications require HTTPS. Complete the certificate step first.</div>
251
+
252
+ <button class="btn" id="push-enable-btn" onclick="enablePush()" style="width:100%">Enable Push Notifications</button>
253
+ <div id="push-status" class="check-status pending" style="display:none"></div>
254
+
255
+ <div class="btn-row">
256
+ <button class="btn" id="push-next" onclick="nextStep()" style="display:none;width:100%">Finish</button>
257
+ </div>
258
+ </div>
259
+
260
+ <!-- Done -->
261
+ <div class="step-card" id="step-done">
262
+ <div class="done-card">
263
+ <div class="done-icon">&#10003;</div>
264
+ <div class="done-title">All set!</div>
265
+ <div class="done-desc">Your device is configured. You can change these settings anytime from the app.</div>
266
+ <a class="btn" id="done-link" href="${httpsUrl}">Open Clay</a>
267
+ </div>
268
+ </div>
269
+ </div>
270
+
271
+ <script>
272
+ var httpsUrl = ${JSON.stringify(httpsUrl)};
273
+ var httpUrl = ${JSON.stringify(httpUrl)};
274
+ var hasCert = ${hasCert ? 'true' : 'false'};
275
+ var lanMode = ${lanMode ? 'true' : 'false'};
276
+ var isHttps = location.protocol === "https:";
277
+ var ua = navigator.userAgent;
278
+ var isIOS = /iPhone|iPad|iPod/.test(ua);
279
+ var isAndroid = /Android/i.test(ua);
280
+ var isStandalone = window.matchMedia("(display-mode:standalone)").matches || navigator.standalone;
281
+ var isIPad = /iPad/.test(ua) || (/Macintosh/i.test(ua) && navigator.maxTouchPoints > 1);
282
+ var isSafari = isIOS && /Safari/.test(ua) && !/CriOS|FxiOS|OPiOS|EdgiOS/.test(ua);
283
+
284
+ // Platform visibility
285
+ var platformClass = isIOS ? "platform-ios" : isAndroid ? "platform-android" : "platform-desktop";
286
+ var els = document.querySelectorAll("." + platformClass);
287
+ for (var i = 0; i < els.length; i++) els[i].style.display = "block";
288
+
289
+ // iOS: Safari check and iPad hint
290
+ if (isIOS) {
291
+ if (!isSafari) {
292
+ var warn = document.getElementById("ios-not-safari");
293
+ var safariSteps = document.getElementById("ios-safari-steps");
294
+ if (warn) warn.style.display = "flex";
295
+ if (safariSteps) safariSteps.style.display = "none";
296
+ }
297
+ if (isIPad) {
298
+ var hint = document.getElementById("ios-ipad-hint");
299
+ if (hint) hint.style.display = "block";
300
+ }
301
+ }
302
+
303
+ // Tailscale detection
304
+ var isTailscale = /^100\./.test(location.hostname);
305
+ var isLocal = location.hostname === "localhost" || location.hostname === "127.0.0.1";
306
+
307
+ // Detect push subscription, then build steps
308
+ function detectPush() {
309
+ if (!("serviceWorker" in navigator) || (!isHttps && !isLocal)) return Promise.resolve(false);
310
+ // If no SW is registered yet, don't wait for .ready (it never resolves)
311
+ if (!navigator.serviceWorker.controller) return Promise.resolve(false);
312
+ return navigator.serviceWorker.ready
313
+ .then(function(reg) { return reg.pushManager.getSubscription(); })
314
+ .then(function(sub) { return !!sub; })
315
+ .catch(function() { return false; });
316
+ }
317
+
318
+ var steps = [];
319
+ var currentStep = 0;
320
+ var bar = document.getElementById("steps-bar");
321
+ var curEls = document.querySelectorAll(".step-cur");
322
+ var totalEls = document.querySelectorAll(".step-total");
323
+
324
+ // Step offset: when continuing from browser setup (PWA was installed), carry over step count
325
+ var stepOffset = 0;
326
+ if (isStandalone && localStorage.getItem("setup-pending")) {
327
+ stepOffset = parseInt(localStorage.getItem("setup-pending"), 10) || 0;
328
+ }
329
+
330
+ function buildSteps(hasPushSub) {
331
+ steps = [];
332
+ if (!isTailscale && !isLocal && !lanMode) steps.push("tailscale");
333
+ if (hasCert && !isHttps) steps.push("cert");
334
+ if (isAndroid) {
335
+ // Android: push first (works in browser), then PWA as optional
336
+ if ((isHttps || isLocal) && !hasPushSub) steps.push("push");
337
+ if (!isStandalone) steps.push("pwa");
338
+ } else {
339
+ // iOS: PWA required for push, so install first
340
+ if (!isStandalone) steps.push("pwa");
341
+ if ((isHttps || isLocal) && !hasPushSub) steps.push("push");
342
+ }
343
+ steps.push("done");
344
+
345
+ // Trigger HTTPS check now that steps are built
346
+ if (steps.indexOf("cert") !== -1) {
347
+ if (isHttps) {
348
+ certStatus.className = "check-status ok";
349
+ certStatus.textContent = "HTTPS connection verified";
350
+ certNext.disabled = false;
351
+ certNext.textContent = "Next";
352
+ } else {
353
+ checkHttps();
354
+ }
355
+ }
356
+
357
+ // PWA: mark setup as pending so the app redirects here on first standalone launch
358
+ if (steps.indexOf("pwa") !== -1) {
359
+ var stepsBeforePwa = steps.indexOf("pwa");
360
+ localStorage.setItem("setup-pending", String(stepsBeforePwa + 1));
361
+ }
362
+
363
+ // Android: PWA is optional, show skip button and update text
364
+ if (isAndroid && steps.indexOf("pwa") !== -1) {
365
+ var pwaSkip = document.getElementById("pwa-skip");
366
+ var pwaStatus = document.getElementById("pwa-status");
367
+ if (pwaSkip) pwaSkip.style.display = "block";
368
+ if (pwaStatus) pwaStatus.textContent = "Optional: install for quick access and full-screen experience.";
369
+ }
370
+
371
+ // Push: show warning if not on HTTPS
372
+ if (!isHttps && !isLocal) {
373
+ pushBtn.style.display = "none";
374
+ pushNeedsHttps.style.display = "flex";
375
+ pushNext.style.display = "block";
376
+ pushNext.textContent = "Finish anyway";
377
+ }
378
+
379
+ bar.innerHTML = "";
380
+ var stepCount = steps.length - 1;
381
+ var displayTotal = stepCount + stepOffset;
382
+ if (displayTotal <= 1) {
383
+ bar.style.display = "none";
384
+ var labels = document.querySelectorAll(".step-label");
385
+ for (var i = 0; i < labels.length; i++) labels[i].style.display = "none";
386
+ } else {
387
+ for (var i = 0; i < displayTotal; i++) {
388
+ var pip = document.createElement("div");
389
+ pip.className = "pip" + (i < stepOffset ? " done" : "");
390
+ bar.appendChild(pip);
391
+ }
392
+ for (var i = 0; i < totalEls.length; i++) totalEls[i].textContent = displayTotal;
393
+ }
394
+ }
395
+
396
+ function showStep(idx) {
397
+ currentStep = idx;
398
+ var cards = document.querySelectorAll(".step-card");
399
+ for (var i = 0; i < cards.length; i++) cards[i].classList.remove("active");
400
+ document.getElementById("step-" + steps[idx]).classList.add("active");
401
+
402
+ var pips = bar.querySelectorAll(".pip");
403
+ var displayIdx = idx + stepOffset;
404
+ for (var i = 0; i < pips.length; i++) {
405
+ pips[i].className = "pip" + (i < displayIdx ? " done" : i === displayIdx ? " active" : "");
406
+ }
407
+
408
+ for (var i = 0; i < curEls.length; i++) curEls[i].textContent = displayIdx + 1;
409
+ }
410
+
411
+ function nextStep() {
412
+ // After cert step on HTTP, redirect to HTTPS for remaining steps
413
+ if (!isHttps && steps[currentStep] === "cert") {
414
+ location.replace(httpsUrl + "/setup" + (lanMode ? "?mode=lan" : ""));
415
+ return;
416
+ }
417
+ if (currentStep < steps.length - 1) showStep(currentStep + 1);
418
+ }
419
+
420
+ // --- Step: Tailscale ---
421
+ var tsStatus = document.getElementById("ts-status");
422
+ var tsNext = document.getElementById("ts-next");
423
+ var tsUrlHint = document.getElementById("tailscale-url-hint");
424
+
425
+ if (isTailscale) {
426
+ tsStatus.className = "check-status ok";
427
+ tsStatus.textContent = "Connected via Tailscale (" + location.hostname + ")";
428
+ tsNext.disabled = false;
429
+ tsNext.textContent = "Next";
430
+ } else if (isLocal) {
431
+ tsStatus.className = "check-status ok";
432
+ tsStatus.textContent = "Running locally. Tailscale is optional.";
433
+ tsNext.disabled = false;
434
+ tsNext.textContent = "Next";
435
+ } else {
436
+ tsStatus.className = "check-status warn";
437
+ tsStatus.textContent = "You are not on a Tailscale network. Install Tailscale and access the relay via your 100.x.x.x IP.";
438
+ tsNext.disabled = false;
439
+ tsNext.textContent = "Next";
440
+ }
441
+
442
+ // Show the Tailscale URL hint
443
+ if (httpsUrl.indexOf("100.") !== -1) {
444
+ tsUrlHint.textContent = "Your relay: " + httpsUrl;
445
+ } else if (httpUrl.indexOf("100.") !== -1) {
446
+ tsUrlHint.textContent = "Your relay: " + httpUrl;
447
+ }
448
+
449
+ // --- Step: Certificate ---
450
+ // Same pattern as main page HTTP->HTTPS check: fetch httpsUrl/info (has CORS headers).
451
+ // If cert is trusted, fetch succeeds -> enable Next. Otherwise show retry.
452
+ var certStatus = document.getElementById("cert-status");
453
+ var certNext = document.getElementById("cert-next");
454
+ var certRetry = document.getElementById("cert-retry");
455
+
456
+ function checkHttps() {
457
+ certStatus.className = "check-status pending";
458
+ certStatus.textContent = "Checking HTTPS connection...";
459
+ certRetry.style.display = "none";
460
+ certNext.disabled = true;
461
+ certNext.textContent = "Verifying...";
462
+
463
+ var ac = new AbortController();
464
+ setTimeout(function() { ac.abort(); }, 3000);
465
+ fetch(httpsUrl + "/info", { signal: ac.signal, mode: "no-cors" })
466
+ .then(function() {
467
+ // Any response (even opaque/401) means TLS handshake succeeded = cert is trusted
468
+ certStatus.className = "check-status ok";
469
+ certStatus.textContent = "HTTPS connection verified. Certificate is trusted.";
470
+ certNext.disabled = false;
471
+ certNext.textContent = "Next";
472
+ certRetry.style.display = "none";
473
+ })
474
+ .catch(function() {
475
+ certStatus.className = "check-status warn";
476
+ certStatus.textContent = "Certificate not trusted yet. Install it above, then retry.";
477
+ certRetry.style.display = "block";
478
+ certNext.disabled = true;
479
+ certNext.textContent = "Waiting for HTTPS...";
480
+ });
481
+ }
482
+
483
+ // cert check is now triggered inside buildSteps() after steps array is populated
484
+
485
+ // PWA setup-pending flag is now set inside buildSteps()
486
+
487
+ // --- Confetti ---
488
+ function fireConfetti() {
489
+ var canvas = document.createElement("canvas");
490
+ canvas.style.cssText = "position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:9999";
491
+ canvas.width = window.innerWidth;
492
+ canvas.height = window.innerHeight;
493
+ document.body.appendChild(canvas);
494
+ var ctx = canvas.getContext("2d");
495
+ var particles = [];
496
+ var colors = ["#DA7756","#57AB5A","#6CB6FF","#E8D44D","#DB61A2","#F0883E"];
497
+ for (var i = 0; i < 100; i++) {
498
+ var angle = Math.random() * Math.PI * 2;
499
+ var speed = Math.random() * 8 + 4;
500
+ particles.push({
501
+ x: canvas.width / 2,
502
+ y: canvas.height * 0.45,
503
+ vx: Math.cos(angle) * speed * (0.6 + Math.random()),
504
+ vy: Math.sin(angle) * speed * (0.6 + Math.random()) - 4,
505
+ color: colors[Math.floor(Math.random() * colors.length)],
506
+ w: Math.random() * 8 + 4,
507
+ h: Math.random() * 4 + 2,
508
+ rot: Math.random() * 360,
509
+ rotV: (Math.random() - 0.5) * 12,
510
+ alpha: 1
511
+ });
512
+ }
513
+ function tick() {
514
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
515
+ var alive = false;
516
+ for (var i = 0; i < particles.length; i++) {
517
+ var p = particles[i];
518
+ if (p.alpha <= 0) continue;
519
+ alive = true;
520
+ p.x += p.vx;
521
+ p.y += p.vy;
522
+ p.vy += 0.35;
523
+ p.vx *= 0.99;
524
+ p.rot += p.rotV;
525
+ p.alpha -= 0.008;
526
+ ctx.save();
527
+ ctx.translate(p.x, p.y);
528
+ ctx.rotate(p.rot * Math.PI / 180);
529
+ ctx.globalAlpha = Math.max(0, p.alpha);
530
+ ctx.fillStyle = p.color;
531
+ ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
532
+ ctx.restore();
533
+ }
534
+ if (alive) requestAnimationFrame(tick);
535
+ else canvas.parentNode && canvas.parentNode.removeChild(canvas);
536
+ }
537
+ requestAnimationFrame(tick);
538
+ }
539
+
540
+ // --- Step: Push ---
541
+ var pushBtn = document.getElementById("push-enable-btn");
542
+ var pushStatus = document.getElementById("push-status");
543
+ var pushNeedsHttps = document.getElementById("push-needs-https");
544
+ var pushNext = document.getElementById("push-next");
545
+
546
+ function pushDone() {
547
+ pushBtn.style.display = "none";
548
+ pushStatus.style.display = "flex";
549
+ pushStatus.className = "check-status ok";
550
+ pushStatus.textContent = "Push notifications enabled!";
551
+ fireConfetti();
552
+ navigator.serviceWorker.ready.then(function(reg) {
553
+ reg.showNotification("\ud83c\udf89 Welcome to Clay!", {
554
+ body: "\ud83d\udd14 You\u2019ll be notified when Claude responds.",
555
+ tag: "claude-welcome",
556
+ });
557
+ }).catch(function() {});
558
+ setTimeout(function() { nextStep(); }, 1200);
559
+ }
560
+
561
+ // Push HTTPS check is now done inside buildSteps()
562
+
563
+ function enablePush() {
564
+ pushBtn.disabled = true;
565
+ pushBtn.textContent = "Requesting permission...";
566
+
567
+ if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
568
+ pushStatus.style.display = "flex";
569
+ pushStatus.className = "check-status warn";
570
+ pushStatus.textContent = "Push notifications are not supported in this browser.";
571
+ pushBtn.style.display = "none";
572
+ pushNext.style.display = "block";
573
+ pushNext.textContent = "Finish anyway";
574
+ return;
575
+ }
576
+
577
+ navigator.serviceWorker.register("/sw.js")
578
+ .then(function() { return navigator.serviceWorker.ready; })
579
+ .then(function(reg) {
580
+ return fetch("/api/vapid-public-key", { cache: "no-store" })
581
+ .then(function(r) { return r.json(); })
582
+ .then(function(data) {
583
+ if (!data.publicKey) throw new Error("No VAPID key");
584
+ var raw = atob(data.publicKey.replace(/-/g, "+").replace(/_/g, "/"));
585
+ var key = new Uint8Array(raw.length);
586
+ for (var i = 0; i < raw.length; i++) key[i] = raw.charCodeAt(i);
587
+ return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key });
588
+ });
589
+ })
590
+ .then(function(sub) {
591
+ var prevEndpoint = localStorage.getItem("push-endpoint");
592
+ localStorage.setItem("push-endpoint", sub.endpoint);
593
+ var payload = { subscription: sub.toJSON() };
594
+ if (prevEndpoint && prevEndpoint !== sub.endpoint) {
595
+ payload.replaceEndpoint = prevEndpoint;
596
+ }
597
+ return fetch("/api/push-subscribe", {
598
+ method: "POST",
599
+ headers: { "Content-Type": "application/json" },
600
+ body: JSON.stringify(payload),
601
+ });
602
+ })
603
+ .then(pushDone)
604
+ .catch(function(err) {
605
+ pushBtn.disabled = false;
606
+ pushBtn.textContent = "Enable Push Notifications";
607
+ pushStatus.style.display = "flex";
608
+ pushNext.style.display = "block";
609
+ pushNext.textContent = "Finish anyway";
610
+ if (Notification.permission === "denied") {
611
+ pushStatus.className = "check-status warn";
612
+ pushStatus.textContent = "Notification permission was denied. Enable it in browser settings.";
613
+ } else {
614
+ pushStatus.className = "check-status warn";
615
+ pushStatus.textContent = "Could not enable push: " + (err.message || "unknown error");
616
+ }
617
+ });
618
+ }
619
+
620
+ // Done: clear setup-pending flag and link to app
621
+ var doneLink = document.getElementById("done-link");
622
+ doneLink.onclick = function() {
623
+ localStorage.removeItem("setup-pending");
624
+ localStorage.setItem("setup-done", "1");
625
+ };
626
+ if (isStandalone) {
627
+ doneLink.href = "/";
628
+ } else if (isHttps) {
629
+ doneLink.href = "/";
630
+ } else {
631
+ doneLink.href = httpsUrl;
632
+ }
633
+
634
+ // Init: try HTTPS redirect first (same as main page), then build steps
635
+ function init() {
636
+ detectPush().then(function(hasPushSub) {
637
+ buildSteps(hasPushSub);
638
+ showStep(0);
639
+ });
640
+ }
641
+
642
+ if (!isHttps && !isLocal) {
643
+ // Try redirecting to HTTPS like the main page does
644
+ fetch("/https-info").then(function(r) { return r.json(); }).then(function(info) {
645
+ if (!info.httpsUrl) { init(); return; }
646
+ var ac = new AbortController();
647
+ setTimeout(function() { ac.abort(); }, 3000);
648
+ fetch(info.httpsUrl + "/info", { signal: ac.signal, mode: "no-cors" })
649
+ .then(function() { location.replace(info.httpsUrl + "/setup" + (lanMode ? "?mode=lan" : "")); })
650
+ .catch(function() { init(); });
651
+ }).catch(function() { init(); });
652
+ } else {
653
+ init();
654
+ }
655
+ </script>
656
+ </body></html>`;
657
+ }
658
+
659
+
660
+ function escapeHtml(s) {
661
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
662
+ }
663
+
664
+ module.exports = { pinPageHtml, setupPageHtml };