@tomorrowos/sdk 1.0.1 → 1.1.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.
- package/REPLIT_SETUP.md +229 -193
- package/VERCEL_QUESTIONS.md +118 -90
- package/VERCEL_SETUP.md +1087 -1056
- package/package.json +1 -1
- package/templates/cms-starter/.env.example +4 -0
- package/templates/cms-starter/README.md +12 -0
- package/templates/cms-starter/cms-auth.ts +292 -0
- package/templates/cms-starter/package.json +2 -2
- package/templates/cms-starter/public/assets/player-download/windows.svg +1 -0
- package/templates/cms-starter/public/index.html +10 -0
- package/templates/cms-starter/public/panel.css +4 -4
- package/templates/cms-starter/server.ts +17 -2
- package/templates/cms-starter-v0/.env.example +4 -0
- package/templates/cms-starter-v0/README.md +4 -0
- package/templates/cms-starter-v0/cms-app.ts +3 -0
- package/templates/cms-starter-v0/cms-auth.ts +292 -0
- package/templates/cms-starter-v0/cms-panel/assets/player-download/windows.svg +1 -0
- package/templates/cms-starter-v0/cms-panel/index.html +10 -0
- package/templates/cms-starter-v0/cms-panel/panel.css +4 -4
- package/templates/cms-starter-v0/package.json +2 -2
package/package.json
CHANGED
|
@@ -10,6 +10,18 @@ npm install
|
|
|
10
10
|
npm run dev
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
### Optional CMS password
|
|
14
|
+
|
|
15
|
+
Set `CMS_PASSWORD` in `.env` (or host Secrets) to lock the admin UI:
|
|
16
|
+
|
|
17
|
+
```env
|
|
18
|
+
CMS_PASSWORD=your-strong-password
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- **Optional:** leave unset → Control Panel stays open (dev mode).
|
|
22
|
+
- **Set later:** add the env var and restart the server.
|
|
23
|
+
- Sign in at `/login` when the password is enabled.
|
|
24
|
+
|
|
13
25
|
By default, the starter uses SQLite at `data/tomorrowos.db`. The database is
|
|
14
26
|
created by `tomorrowos init` and the server will also create missing tables on
|
|
15
27
|
startup.
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional Control Panel auth for CMS starters.
|
|
3
|
+
*
|
|
4
|
+
* - CMS_PASSWORD unset → admin UI stays open (dev mode).
|
|
5
|
+
* - CMS_PASSWORD set → browser admin requires login; device paths stay public.
|
|
6
|
+
*
|
|
7
|
+
* Device-safe (always open): WebSocket upgrades (not handled here),
|
|
8
|
+
* GET /brand.json, GET /devices, GET /status, GET/HEAD /uploads/*.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
12
|
+
import type { IncomingMessage, Server, ServerResponse } from "http";
|
|
13
|
+
|
|
14
|
+
const COOKIE_NAME = "tomorrowos_panel";
|
|
15
|
+
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
export function getCmsPassword(): string {
|
|
18
|
+
return String(process.env.CMS_PASSWORD || "").trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isCmsAuthEnabled(): boolean {
|
|
22
|
+
return getCmsPassword().length > 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isDevicePublicPath(method: string, pathname: string): boolean {
|
|
26
|
+
const m = method.toUpperCase();
|
|
27
|
+
if (m !== "GET" && m !== "HEAD") return false;
|
|
28
|
+
if (pathname === "/brand.json") return true;
|
|
29
|
+
if (pathname === "/devices") return true;
|
|
30
|
+
if (pathname === "/status") return true;
|
|
31
|
+
if (pathname === "/uploads" || pathname.startsWith("/uploads/")) return true;
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function safeEqualString(a: string, b: string): boolean {
|
|
36
|
+
const left = Buffer.from(a, "utf8");
|
|
37
|
+
const right = Buffer.from(b, "utf8");
|
|
38
|
+
if (left.length !== right.length) return false;
|
|
39
|
+
return timingSafeEqual(left, right);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function signSession(password: string, exp: number): string {
|
|
43
|
+
const payload = `exp=${exp}`;
|
|
44
|
+
const sig = createHmac("sha256", password).update(payload).digest("base64url");
|
|
45
|
+
return `${payload}.${sig}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function verifySession(password: string, token: string | undefined): boolean {
|
|
49
|
+
if (!token) return false;
|
|
50
|
+
const parts = token.split(".");
|
|
51
|
+
if (parts.length !== 2) return false;
|
|
52
|
+
const [payload, sig] = parts;
|
|
53
|
+
const expected = createHmac("sha256", password).update(payload).digest("base64url");
|
|
54
|
+
if (!safeEqualString(sig, expected)) return false;
|
|
55
|
+
const expMatch = /^exp=(\d+)$/.exec(payload);
|
|
56
|
+
if (!expMatch) return false;
|
|
57
|
+
const exp = Number(expMatch[1]);
|
|
58
|
+
return Number.isFinite(exp) && Date.now() <= exp;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseCookies(req: IncomingMessage): Record<string, string> {
|
|
62
|
+
const header = req.headers.cookie;
|
|
63
|
+
if (!header) return {};
|
|
64
|
+
const out: Record<string, string> = {};
|
|
65
|
+
for (const part of header.split(";")) {
|
|
66
|
+
const idx = part.indexOf("=");
|
|
67
|
+
if (idx <= 0) continue;
|
|
68
|
+
const key = part.slice(0, idx).trim();
|
|
69
|
+
const value = part.slice(idx + 1).trim();
|
|
70
|
+
out[key] = decodeURIComponent(value);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function wantsHtml(req: IncomingMessage): boolean {
|
|
76
|
+
const accept = String(req.headers.accept || "");
|
|
77
|
+
if (accept.includes("application/json") && !accept.includes("text/html")) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (accept.includes("text/html")) return true;
|
|
81
|
+
if (!accept || accept.includes("*/*")) return true;
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isHttpsRequest(req: IncomingMessage): boolean {
|
|
86
|
+
const xf = String(req.headers["x-forwarded-proto"] || "")
|
|
87
|
+
.split(",")[0]
|
|
88
|
+
.trim()
|
|
89
|
+
.toLowerCase();
|
|
90
|
+
if (xf === "https") return true;
|
|
91
|
+
return Boolean((req.socket as { encrypted?: boolean }).encrypted);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function sendJson(res: ServerResponse, status: number, body: Record<string, unknown>) {
|
|
95
|
+
const payload = JSON.stringify(body);
|
|
96
|
+
res.writeHead(status, {
|
|
97
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
98
|
+
"Cache-Control": "no-store"
|
|
99
|
+
});
|
|
100
|
+
res.end(payload);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function loginPageHtml(error?: string): string {
|
|
104
|
+
const err = error
|
|
105
|
+
? `<p style="color:#b91c1c;margin:0 0 1rem;font-size:0.95rem">${escapeHtml(error)}</p>`
|
|
106
|
+
: "";
|
|
107
|
+
return `<!DOCTYPE html>
|
|
108
|
+
<html lang="en">
|
|
109
|
+
<head>
|
|
110
|
+
<meta charset="utf-8" />
|
|
111
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
112
|
+
<title>TomorrowOS Control Panel — Login</title>
|
|
113
|
+
<style>
|
|
114
|
+
body { font-family: system-ui, sans-serif; background: #f4f4f5; margin: 0; min-height: 100vh; display: grid; place-items: center; }
|
|
115
|
+
.card { background: #fff; border: 1px solid #e4e4e7; border-radius: 12px; padding: 2rem; width: min(420px, 92vw); box-shadow: 0 8px 24px rgba(0,0,0,.04); }
|
|
116
|
+
h1 { font-size: 1.25rem; margin: 0 0 .35rem; }
|
|
117
|
+
p.hint { color: #71717a; font-size: .9rem; margin: 0 0 1.25rem; line-height: 1.4; }
|
|
118
|
+
label { display: block; font-size: .85rem; font-weight: 600; margin-bottom: .4rem; }
|
|
119
|
+
input[type=password] { width: 100%; box-sizing: border-box; padding: .7rem .8rem; border: 1px solid #d4d4d8; border-radius: 8px; font-size: 1rem; }
|
|
120
|
+
button { margin-top: 1rem; width: 100%; padding: .75rem 1rem; border: 0; border-radius: 8px; background: #111; color: #fff; font-weight: 600; cursor: pointer; }
|
|
121
|
+
button:hover { background: #27272a; }
|
|
122
|
+
</style>
|
|
123
|
+
</head>
|
|
124
|
+
<body>
|
|
125
|
+
<form class="card" method="POST" action="/login" autocomplete="current-password">
|
|
126
|
+
<h1>CMS login</h1>
|
|
127
|
+
<p class="hint">Enter your CMS password to login.</p>
|
|
128
|
+
${err}
|
|
129
|
+
<label for="password">Password</label>
|
|
130
|
+
<input id="password" name="password" type="password" required autofocus />
|
|
131
|
+
<button type="submit">Sign in</button>
|
|
132
|
+
</form>
|
|
133
|
+
</body>
|
|
134
|
+
</html>`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function escapeHtml(value: string): string {
|
|
138
|
+
return value
|
|
139
|
+
.replace(/&/g, "&")
|
|
140
|
+
.replace(/</g, "<")
|
|
141
|
+
.replace(/>/g, ">")
|
|
142
|
+
.replace(/"/g, """);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function readBody(req: IncomingMessage): Promise<string> {
|
|
146
|
+
const chunks: Buffer[] = [];
|
|
147
|
+
for await (const chunk of req) {
|
|
148
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
149
|
+
}
|
|
150
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseFormPassword(raw: string, contentType: string | undefined): string {
|
|
154
|
+
if ((contentType || "").includes("application/json")) {
|
|
155
|
+
try {
|
|
156
|
+
const data = JSON.parse(raw) as { password?: unknown };
|
|
157
|
+
return String(data.password || "");
|
|
158
|
+
} catch {
|
|
159
|
+
return "";
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const params = new URLSearchParams(raw);
|
|
163
|
+
return String(params.get("password") || "");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function setSessionCookie(res: ServerResponse, token: string, secure: boolean) {
|
|
167
|
+
const maxAge = Math.floor(SESSION_TTL_MS / 1000);
|
|
168
|
+
const securePart = secure ? "; Secure" : "";
|
|
169
|
+
res.setHeader(
|
|
170
|
+
"Set-Cookie",
|
|
171
|
+
`${COOKIE_NAME}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${securePart}`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function clearSessionCookie(res: ServerResponse, secure: boolean) {
|
|
176
|
+
const securePart = secure ? "; Secure" : "";
|
|
177
|
+
res.setHeader(
|
|
178
|
+
"Set-Cookie",
|
|
179
|
+
`${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${securePart}`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Returns true if the request was fully handled (caller must not forward to SDK).
|
|
185
|
+
* Returns false if the request should continue to TomorrowOS HTTP handling.
|
|
186
|
+
*/
|
|
187
|
+
export async function handleCmsAuth(
|
|
188
|
+
req: IncomingMessage,
|
|
189
|
+
res: ServerResponse
|
|
190
|
+
): Promise<boolean> {
|
|
191
|
+
if (!isCmsAuthEnabled()) return false;
|
|
192
|
+
|
|
193
|
+
const password = getCmsPassword();
|
|
194
|
+
const method = String(req.method || "GET").toUpperCase();
|
|
195
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
196
|
+
let pathname = url.pathname || "/";
|
|
197
|
+
if (pathname.length > 1 && pathname.endsWith("/")) {
|
|
198
|
+
pathname = pathname.slice(0, -1);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (isDevicePublicPath(method, pathname)) return false;
|
|
202
|
+
|
|
203
|
+
if (method === "GET" && pathname === "/login") {
|
|
204
|
+
res.writeHead(200, {
|
|
205
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
206
|
+
"Cache-Control": "no-store"
|
|
207
|
+
});
|
|
208
|
+
res.end(loginPageHtml());
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (method === "POST" && pathname === "/logout") {
|
|
213
|
+
clearSessionCookie(res, isHttpsRequest(req));
|
|
214
|
+
res.writeHead(303, { Location: "/login" });
|
|
215
|
+
res.end();
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (method === "POST" && pathname === "/login") {
|
|
220
|
+
const raw = await readBody(req);
|
|
221
|
+
const submitted = parseFormPassword(raw, req.headers["content-type"]);
|
|
222
|
+
if (!safeEqualString(submitted, password)) {
|
|
223
|
+
res.writeHead(401, {
|
|
224
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
225
|
+
"Cache-Control": "no-store"
|
|
226
|
+
});
|
|
227
|
+
res.end(loginPageHtml("Incorrect password."));
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
const token = signSession(password, Date.now() + SESSION_TTL_MS);
|
|
231
|
+
setSessionCookie(res, token, isHttpsRequest(req));
|
|
232
|
+
const acceptJson =
|
|
233
|
+
String(req.headers.accept || "").includes("application/json") ||
|
|
234
|
+
String(req.headers["content-type"] || "").includes("application/json");
|
|
235
|
+
if (acceptJson) {
|
|
236
|
+
sendJson(res, 200, { status: "success" });
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
res.writeHead(303, { Location: "/" });
|
|
240
|
+
res.end();
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const cookies = parseCookies(req);
|
|
245
|
+
if (verifySession(password, cookies[COOKIE_NAME])) return false;
|
|
246
|
+
|
|
247
|
+
if (wantsHtml(req) && method === "GET") {
|
|
248
|
+
res.writeHead(303, { Location: "/login" });
|
|
249
|
+
res.end();
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
sendJson(res, 401, {
|
|
254
|
+
status: "failed",
|
|
255
|
+
error:
|
|
256
|
+
"Control Panel authentication required. Sign in at /login or set cookie after POST /login."
|
|
257
|
+
});
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Wrap an http.Server created by TomorrowOS.listen so panel routes can require CMS_PASSWORD. */
|
|
262
|
+
export function attachCmsAuth(server: Server): void {
|
|
263
|
+
const sdkRequestListeners = server.listeners("request").slice() as Array<
|
|
264
|
+
(req: IncomingMessage, res: ServerResponse) => void
|
|
265
|
+
>;
|
|
266
|
+
server.removeAllListeners("request");
|
|
267
|
+
server.on("request", (req, res) => {
|
|
268
|
+
void (async () => {
|
|
269
|
+
try {
|
|
270
|
+
if (await handleCmsAuth(req, res)) return;
|
|
271
|
+
for (const listener of sdkRequestListeners) {
|
|
272
|
+
listener.call(server, req, res);
|
|
273
|
+
}
|
|
274
|
+
} catch (err) {
|
|
275
|
+
console.error("[TomorrowOS] cms auth / request failed:", err);
|
|
276
|
+
if (!res.headersSent) {
|
|
277
|
+
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
|
|
278
|
+
res.end(JSON.stringify({ status: "failed", error: "Internal server error" }));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
})();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
if (isCmsAuthEnabled()) {
|
|
285
|
+
console.log("[TomorrowOS] CMS_PASSWORD is set — Control Panel requires /login");
|
|
286
|
+
console.log(
|
|
287
|
+
"[TomorrowOS] Device paths remain open: WebSocket, /brand.json, /devices, /uploads/*"
|
|
288
|
+
);
|
|
289
|
+
} else {
|
|
290
|
+
console.log("[TomorrowOS] CMS_PASSWORD unset — Control Panel is open (dev mode)");
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "my-cms",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "CMS server on @tomorrowos/sdk. Add your UI (React, static files, etc.) alongside this server.",
|
|
5
5
|
"private": true,
|
|
6
6
|
"type": "module",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"start": "tsx server.ts"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@tomorrowos/sdk": "^1.0
|
|
15
|
+
"@tomorrowos/sdk": "^1.1.0",
|
|
16
16
|
"dotenv": "^17.2.3",
|
|
17
17
|
"tsx": "^4.19.0"
|
|
18
18
|
},
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" height="110" width="480" fill="#0078d6" viewBox="0 0 480 110"><path d="M217.562 22.397l-17.55 62.132h-8.533l-12.783-45.407c-.547-1.935-.882-4.044-.996-6.326h-.172a32.143 32.143 0 01-1.126 6.24l-12.867 45.493h-8.45l-18.198-62.132h8.014l13.216 47.66a31.354 31.354 0 011.04 6.24h.218c.144-1.532.588-3.61 1.341-6.24l13.736-47.66h6.975l13.17 48.008c.462 1.645.807 3.582 1.04 5.805h.172c.115-1.5.505-3.493 1.172-5.978L209.675 22.4h7.887v-.003zm9.786 6.498c-1.272 0-2.355-.434-3.25-1.301s-1.341-1.962-1.341-3.293c0-1.326.449-2.434 1.341-3.317.898-.88 1.978-1.32 3.25-1.32 1.3 0 2.404.443 3.316 1.32.908.883 1.364 1.988 1.364 3.317 0 1.272-.456 2.355-1.364 3.25-.912.897-2.015 1.344-3.316 1.344zm3.466 55.634h-7.106V40.16h7.106zm47.754 0h-7.106V59.227c0-9.416-3.438-14.124-10.31-14.124-3.553 0-6.494 1.336-8.819 4.008-2.327 2.672-3.488 6.044-3.488 10.116v25.302h-7.107V40.16h7.107v7.365h.172c3.35-5.603 8.202-8.404 14.558-8.404 4.852 0 8.564 1.567 11.135 4.7 2.573 3.134 3.856 7.662 3.856 13.582v27.125h.003zm50.549 0h-7.107v-7.54h-.172c-3.293 5.72-8.38 8.58-15.252 8.58-5.575 0-10.03-1.986-13.366-5.959-3.334-3.97-5.005-9.38-5.005-16.226 0-7.337 1.848-13.216 5.547-17.632 3.697-4.422 8.62-6.63 14.776-6.63 6.092 0 10.526 2.398 13.3 7.192h.172v-27.47h7.107zm-7.104-20.06v-6.543c0-3.58-1.187-6.612-3.552-9.1-2.37-2.481-5.372-3.726-9.013-3.726-4.333 0-7.743 1.59-10.225 4.766-2.484 3.18-3.727 7.568-3.727 13.171 0 5.114 1.192 9.15 3.575 12.109 2.38 2.96 5.58 4.441 9.596 4.441 3.958 0 7.17-1.427 9.642-4.29 2.467-2.857 3.704-6.47 3.704-10.827zm38.166 21.099c-6.559 0-11.792-2.071-15.706-6.216-3.915-4.146-5.87-9.642-5.87-16.487 0-7.452 2.037-13.273 6.11-17.46 4.07-4.19 9.574-6.286 16.508-6.286 6.612 0 11.779 2.036 15.488 6.11 3.712 4.07 5.567 9.72 5.567 16.941 0 7.078-2 12.745-6 17.007-4.001 4.261-9.366 6.391-16.097 6.391zm.52-40.468c-4.566 0-8.177 1.552-10.831 4.657-2.66 3.109-3.986 7.388-3.986 12.847 0 5.258 1.342 9.4 4.032 12.435 2.684 3.03 6.282 4.548 10.787 4.548 4.594 0 8.123-1.488 10.595-4.464 2.47-2.974 3.704-7.206 3.704-12.694 0-5.548-1.235-9.82-3.704-12.826-2.472-3-6.003-4.503-10.597-4.503zm85.611-4.939l-13.3 44.368h-7.368l-9.14-31.76c-.347-1.214-.58-2.583-.692-4.114h-.172c-.086 1.039-.39 2.38-.91 4.03l-9.92 31.846H397.7l-13.432-44.367h7.451l9.183 33.359c.287 1.012.49 2.34.606 3.985h.347c.087-1.27.348-2.626.781-4.071l10.225-33.276h6.498l9.185 33.448c.287 1.07.505 2.399.652 3.986h.347c.056-1.126.302-2.454.735-3.986l9.013-33.448zm3.293 42.763v-7.626c3.87 2.862 8.13 4.29 12.78 4.29 6.24 0 9.358-2.08 9.358-6.24 0-1.184-.266-2.188-.798-3.012-.533-.824-1.258-1.551-2.166-2.188-.91-.634-1.98-1.207-3.204-1.711a231.663 231.663 0 00-3.965-1.582c-1.965-.781-3.692-1.567-5.177-2.363-1.488-.794-2.728-1.689-3.727-2.687a10.01 10.01 0 01-2.254-3.403c-.505-1.27-.758-2.756-.758-4.462 0-2.079.477-3.922 1.427-5.524.956-1.605 2.226-2.946 3.813-4.031 1.59-1.083 3.403-1.9 5.441-2.45a24.143 24.143 0 016.3-.82c3.841 0 7.279.666 10.314 1.992v7.192c-3.268-2.137-7.023-3.207-11.267-3.207-1.329 0-2.525.152-3.595.457-1.07.301-1.985.73-2.75 1.277-.766.55-1.357 1.207-1.776 1.97a5.185 5.185 0 00-.628 2.536c0 1.156.208 2.122.628 2.905.419.778 1.032 1.47 1.841 2.079.811.603 1.793 1.154 2.946 1.643 1.159.492 2.472 1.027 3.948 1.605 1.96.753 3.724 1.526 5.283 2.317 1.56.794 2.888 1.691 3.985 2.687a10.576 10.576 0 012.536 3.443c.59 1.303.887 2.847.887 4.637 0 2.196-.48 4.102-1.45 5.72a12.403 12.403 0 01-3.877 4.028c-1.62 1.07-3.48 1.864-5.59 2.384-2.104.52-4.315.78-6.627.78-4.571.008-8.529-.874-11.878-2.636zM105.79 0L46.907 8.592v42.669h58.881zM0 15.432v35.829h42.757V9.195zm0 76.261l42.757 6.308V55.408H0zM105.79 107.3V55.408H46.907v43.207z"/></svg>
|
|
@@ -270,6 +270,16 @@
|
|
|
270
270
|
</a>
|
|
271
271
|
<p class="player-platform-caption">Supported Tizen 6.5 and 7.0</p>
|
|
272
272
|
</div>
|
|
273
|
+
<div class="player-platform-cell">
|
|
274
|
+
<a
|
|
275
|
+
href="https://tmr.sh/app/windows/TomorrowOS-Windows-Setup.exe"
|
|
276
|
+
class="player-platform-link"
|
|
277
|
+
rel="noopener noreferrer"
|
|
278
|
+
>
|
|
279
|
+
<img src="./assets/player-download/windows.svg" alt="Windows" class="player-platform-logo player-platform-logo--wide" />
|
|
280
|
+
</a>
|
|
281
|
+
<p class="player-platform-caption">Supported Windows 11</p>
|
|
282
|
+
</div>
|
|
273
283
|
<div class="player-platform-cell">
|
|
274
284
|
<img src="./assets/player-download/android.svg" alt="Android" class="player-platform-logo player-platform-logo--wide" />
|
|
275
285
|
<p class="player-platform-caption player-platform-caption--muted">Planned</p>
|
|
@@ -1068,7 +1068,7 @@ button.danger {
|
|
|
1068
1068
|
|
|
1069
1069
|
.player-platform-grid {
|
|
1070
1070
|
display: grid;
|
|
1071
|
-
padding:1.5rem;
|
|
1071
|
+
padding: 1.5rem 1.5rem 0.5rem;
|
|
1072
1072
|
|
|
1073
1073
|
grid-template-columns: 1fr 1fr;
|
|
1074
1074
|
gap: 1.75rem 2rem;
|
|
@@ -1080,7 +1080,7 @@ button.danger {
|
|
|
1080
1080
|
flex-direction: column;
|
|
1081
1081
|
align-items: center;
|
|
1082
1082
|
gap: 0.45rem;
|
|
1083
|
-
min-height:
|
|
1083
|
+
min-height: 4.25rem;
|
|
1084
1084
|
}
|
|
1085
1085
|
|
|
1086
1086
|
.player-platform-link {
|
|
@@ -1124,8 +1124,8 @@ button.danger {
|
|
|
1124
1124
|
}
|
|
1125
1125
|
|
|
1126
1126
|
.player-platform-diy {
|
|
1127
|
-
margin-top:
|
|
1128
|
-
padding-top: 1.
|
|
1127
|
+
margin-top: 0.55rem;
|
|
1128
|
+
padding-top: 1.15rem;
|
|
1129
1129
|
border-top: 1px solid #e4e1dc;
|
|
1130
1130
|
display: flex;
|
|
1131
1131
|
flex-direction: column;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* TomorrowOS CMS server — minimal starter.
|
|
3
3
|
* Uses SQLite by default so pairings/playlists survive server restarts.
|
|
4
|
+
*
|
|
5
|
+
* Optional Control Panel lock: set CMS_PASSWORD in env / .env / host Secrets.
|
|
6
|
+
* Device WebSocket + brand.json + /devices + /uploads stay reachable without it.
|
|
4
7
|
*/
|
|
5
8
|
|
|
6
9
|
import "dotenv/config";
|
|
@@ -8,6 +11,7 @@ import { readFileSync } from "fs";
|
|
|
8
11
|
import { fileURLToPath } from "url";
|
|
9
12
|
import { dirname, join } from "path";
|
|
10
13
|
import { createTomorrowOSStore, TomorrowOS } from "@tomorrowos/sdk";
|
|
14
|
+
import { attachCmsAuth } from "./cms-auth.js";
|
|
11
15
|
|
|
12
16
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
17
|
const brand = JSON.parse(readFileSync(join(__dirname, "brand.json"), "utf8"));
|
|
@@ -19,10 +23,21 @@ const store = createTomorrowOSStore({
|
|
|
19
23
|
|
|
20
24
|
const tomorrowos = new TomorrowOS({ brand, store });
|
|
21
25
|
|
|
26
|
+
const port = Number(process.env.PORT) || 3000;
|
|
27
|
+
const host = "0.0.0.0";
|
|
28
|
+
|
|
22
29
|
const server = tomorrowos.listen({
|
|
23
|
-
port
|
|
24
|
-
host
|
|
30
|
+
port,
|
|
31
|
+
host,
|
|
25
32
|
staticRoot: join(__dirname, "public"),
|
|
33
|
+
// Wrap `request` for optional CMS auth, then listen ourselves.
|
|
34
|
+
autoListen: false
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
attachCmsAuth(server);
|
|
38
|
+
|
|
39
|
+
server.listen(port, host, () => {
|
|
40
|
+
console.log(`[TomorrowOS] listening on http://${host}:${port}`);
|
|
26
41
|
});
|
|
27
42
|
|
|
28
43
|
tomorrowos.on("device.paired", (event) => {
|
|
@@ -25,6 +25,10 @@ npm run dev
|
|
|
25
25
|
|
|
26
26
|
Open `http://localhost:3000`. Pairing uses `ws://localhost:3000/` (root path).
|
|
27
27
|
|
|
28
|
+
### Optional CMS password
|
|
29
|
+
|
|
30
|
+
Set `CMS_PASSWORD` in `.env` or Vercel/Replit Secrets to lock the admin UI. Leave unset for open admin. Sign in at `/login` when enabled.
|
|
31
|
+
|
|
28
32
|
### v0-style Preview
|
|
29
33
|
|
|
30
34
|
```bash
|
|
@@ -10,6 +10,7 @@ import { readFileSync } from "fs";
|
|
|
10
10
|
import { fileURLToPath } from "url";
|
|
11
11
|
import { dirname, join } from "path";
|
|
12
12
|
import { createTomorrowOSStore, TomorrowOS } from "@tomorrowos/sdk";
|
|
13
|
+
import { attachCmsAuth } from "./cms-auth.js";
|
|
13
14
|
|
|
14
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
const brand = JSON.parse(readFileSync(join(__dirname, "brand.json"), "utf8"));
|
|
@@ -34,6 +35,8 @@ export const server = tomorrowos.listen({
|
|
|
34
35
|
// autoListen defaults to false when process.env.VERCEL is set
|
|
35
36
|
});
|
|
36
37
|
|
|
38
|
+
// Optional CMS_PASSWORD lock for Control Panel (device paths stay open).
|
|
39
|
+
attachCmsAuth(server);
|
|
37
40
|
tomorrowos.on("device.paired", (event) => {
|
|
38
41
|
console.log(`[TomorrowOS] device paired: ${event.deviceId}`);
|
|
39
42
|
void tomorrowos.pushLatestPolicyToDevice(event.deviceId).then((r) => {
|