@tomorrowos/sdk 0.5.9 → 0.6.1
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/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/pairing-code.d.ts +6 -0
- package/dist/pairing-code.d.ts.map +1 -1
- package/dist/pairing-code.js +19 -1
- package/dist/store/postgres-store.js +253 -253
- package/dist/store/sqlite-store.js +249 -249
- package/dist/tomorrowos.d.ts +14 -0
- package/dist/tomorrowos.d.ts.map +1 -1
- package/dist/tomorrowos.js +114 -2
- package/package.json +1 -1
- package/templates/cms-starter/.env.example +12 -8
- package/templates/cms-starter/package.json +2 -2
- package/templates/cms-starter/public/index.html +12 -0
- package/templates/cms-starter/public/methods.js +65 -0
- package/templates/cms-starter/public/panel.css +14 -0
package/dist/tomorrowos.js
CHANGED
|
@@ -4,7 +4,7 @@ import fs from "fs/promises";
|
|
|
4
4
|
import http from "http";
|
|
5
5
|
import path from "path";
|
|
6
6
|
import { WebSocket, WebSocketServer } from "ws";
|
|
7
|
-
import { generateRandomPairingCode, isValidPairingCodeFormat, normalizePairingCode } from "./pairing-code.js";
|
|
7
|
+
import { generateDeterministicPairingCode, generateRandomPairingCode, isValidPairingCodeFormat, normalizePairingCode, resolvePairingCodeSecret } from "./pairing-code.js";
|
|
8
8
|
import { PlaylistCatalog } from "./playlist-catalog.js";
|
|
9
9
|
import { MemoryStore } from "./store/memory-store.js";
|
|
10
10
|
import { resolveBrandLogoPath, syncProjectAssetsToStaticRoot } from "./brand-assets.js";
|
|
@@ -149,6 +149,9 @@ function sanitizeUploadFilename(name) {
|
|
|
149
149
|
const base = path.basename(String(name || "upload")).replace(/[^\w.\-()+ ]/g, "_");
|
|
150
150
|
return base.slice(0, 180) || "upload";
|
|
151
151
|
}
|
|
152
|
+
function sanitizeStorageSegment(value) {
|
|
153
|
+
return String(value || "item").replace(/[^\w.\-]+/g, "_").slice(0, 160) || "item";
|
|
154
|
+
}
|
|
152
155
|
function inferResourceType(mimeType, filename) {
|
|
153
156
|
const mime = String(mimeType || "").toLowerCase();
|
|
154
157
|
if (mime.startsWith("image/"))
|
|
@@ -393,8 +396,12 @@ export class TomorrowOS extends EventEmitter {
|
|
|
393
396
|
});
|
|
394
397
|
return existing.permanentPairingCode;
|
|
395
398
|
}
|
|
399
|
+
const stableIdentity = String(serialNumber || deviceId || "").trim();
|
|
400
|
+
const secret = resolvePairingCodeSecret();
|
|
396
401
|
for (let attempt = 0; attempt < 32; attempt += 1) {
|
|
397
|
-
const code =
|
|
402
|
+
const code = stableIdentity
|
|
403
|
+
? generateDeterministicPairingCode(stableIdentity, { secret, attempt })
|
|
404
|
+
: generateRandomPairingCode();
|
|
398
405
|
const collision = await this.store.getDeviceRegistryByCode(code);
|
|
399
406
|
if (collision && collision.deviceId !== deviceId)
|
|
400
407
|
continue;
|
|
@@ -573,7 +580,9 @@ export class TomorrowOS extends EventEmitter {
|
|
|
573
580
|
}
|
|
574
581
|
this.staticIndexFile = idx;
|
|
575
582
|
const uploadsDir = path.join(path.resolve(this.staticRoot), "uploads");
|
|
583
|
+
const screenshotsDir = path.join(path.resolve(this.staticRoot), "screenshots");
|
|
576
584
|
void fs.mkdir(uploadsDir, { recursive: true });
|
|
585
|
+
void fs.mkdir(screenshotsDir, { recursive: true });
|
|
577
586
|
void this.refreshResolvedBrandLogo();
|
|
578
587
|
}
|
|
579
588
|
else {
|
|
@@ -817,6 +826,79 @@ export class TomorrowOS extends EventEmitter {
|
|
|
817
826
|
return false;
|
|
818
827
|
}
|
|
819
828
|
}
|
|
829
|
+
getScreenshotsDir() {
|
|
830
|
+
if (!this.staticRoot) {
|
|
831
|
+
throw new Error("Screenshot storage requires listen({ staticRoot })");
|
|
832
|
+
}
|
|
833
|
+
return path.join(path.resolve(this.staticRoot), "screenshots");
|
|
834
|
+
}
|
|
835
|
+
screenshotBaseName(deviceId) {
|
|
836
|
+
return sanitizeStorageSegment(deviceId);
|
|
837
|
+
}
|
|
838
|
+
screenshotExtension(mimeType) {
|
|
839
|
+
const normalized = mimeType.toLowerCase();
|
|
840
|
+
if (normalized === "image/png")
|
|
841
|
+
return ".png";
|
|
842
|
+
if (normalized === "image/webp")
|
|
843
|
+
return ".webp";
|
|
844
|
+
return ".jpg";
|
|
845
|
+
}
|
|
846
|
+
async saveDeviceScreenshot(deviceId, payload) {
|
|
847
|
+
const mimeType = typeof payload.mimeType === "string" && payload.mimeType.startsWith("image/")
|
|
848
|
+
? payload.mimeType
|
|
849
|
+
: "image/jpeg";
|
|
850
|
+
const dataBase64 = typeof payload.dataBase64 === "string" ? payload.dataBase64 : "";
|
|
851
|
+
if (!dataBase64.trim()) {
|
|
852
|
+
throw new Error("Screenshot payload missing dataBase64");
|
|
853
|
+
}
|
|
854
|
+
const dir = this.getScreenshotsDir();
|
|
855
|
+
await fs.mkdir(dir, { recursive: true });
|
|
856
|
+
const base = this.screenshotBaseName(deviceId);
|
|
857
|
+
const ext = this.screenshotExtension(mimeType);
|
|
858
|
+
const filename = `${base}${ext}`;
|
|
859
|
+
const filePath = path.join(dir, filename);
|
|
860
|
+
const metaPath = path.join(dir, `${base}.json`);
|
|
861
|
+
const capturedAt = typeof payload.capturedAt === "string" && payload.capturedAt.trim()
|
|
862
|
+
? payload.capturedAt
|
|
863
|
+
: new Date().toISOString();
|
|
864
|
+
await fs.writeFile(filePath, Buffer.from(dataBase64, "base64"));
|
|
865
|
+
const info = {
|
|
866
|
+
deviceId,
|
|
867
|
+
url: `/screenshots/${filename}`,
|
|
868
|
+
capturedAt,
|
|
869
|
+
mimeType,
|
|
870
|
+
width: typeof payload.width === "number" ? payload.width : undefined,
|
|
871
|
+
height: typeof payload.height === "number" ? payload.height : undefined
|
|
872
|
+
};
|
|
873
|
+
await fs.writeFile(metaPath, JSON.stringify(info, null, 2));
|
|
874
|
+
return info;
|
|
875
|
+
}
|
|
876
|
+
async getLatestDeviceScreenshot(deviceId) {
|
|
877
|
+
const dir = this.getScreenshotsDir();
|
|
878
|
+
const base = this.screenshotBaseName(deviceId);
|
|
879
|
+
const metaPath = path.join(dir, `${base}.json`);
|
|
880
|
+
try {
|
|
881
|
+
const raw = await fs.readFile(metaPath, "utf8");
|
|
882
|
+
const parsed = JSON.parse(raw);
|
|
883
|
+
if (!parsed || typeof parsed.url !== "string")
|
|
884
|
+
return null;
|
|
885
|
+
return parsed;
|
|
886
|
+
}
|
|
887
|
+
catch {
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
async captureDeviceScreenshot(deviceId) {
|
|
892
|
+
const result = await this.sendDeviceCommand(deviceId, "device.screenshot", {});
|
|
893
|
+
if (result.status === "failed") {
|
|
894
|
+
throw new Error(String(result.error ?? "Screenshot failed"));
|
|
895
|
+
}
|
|
896
|
+
const data = (result.data ?? {});
|
|
897
|
+
const screenshot = data.screenshot && typeof data.screenshot === "object"
|
|
898
|
+
? data.screenshot
|
|
899
|
+
: data;
|
|
900
|
+
return this.saveDeviceScreenshot(deviceId, screenshot);
|
|
901
|
+
}
|
|
820
902
|
async handleHttp(req, res) {
|
|
821
903
|
try {
|
|
822
904
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
@@ -963,6 +1045,36 @@ export class TomorrowOS extends EventEmitter {
|
|
|
963
1045
|
sendJson(res, 200, { status: "success", logs: this.getDeviceLogs(deviceId) });
|
|
964
1046
|
return;
|
|
965
1047
|
}
|
|
1048
|
+
const deviceScreenshot = /^\/device\/([^/]+)\/screenshot$/.exec(pathname);
|
|
1049
|
+
if (req.method === "POST" && deviceScreenshot) {
|
|
1050
|
+
const deviceId = decodeURIComponent(deviceScreenshot[1]);
|
|
1051
|
+
try {
|
|
1052
|
+
const screenshot = await this.captureDeviceScreenshot(deviceId);
|
|
1053
|
+
sendJson(res, 200, { status: "success", screenshot });
|
|
1054
|
+
}
|
|
1055
|
+
catch (e) {
|
|
1056
|
+
const msg = e instanceof Error ? e.message : "Screenshot failed";
|
|
1057
|
+
sendJson(res, 400, { status: "failed", error: msg });
|
|
1058
|
+
}
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
const latestScreenshot = /^\/device\/([^/]+)\/screenshot\/latest$/.exec(pathname);
|
|
1062
|
+
if (req.method === "GET" && latestScreenshot) {
|
|
1063
|
+
const deviceId = decodeURIComponent(latestScreenshot[1]);
|
|
1064
|
+
try {
|
|
1065
|
+
const screenshot = await this.getLatestDeviceScreenshot(deviceId);
|
|
1066
|
+
if (!screenshot) {
|
|
1067
|
+
sendJson(res, 404, { status: "failed", error: "No screenshot captured yet" });
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
sendJson(res, 200, { status: "success", screenshot });
|
|
1071
|
+
}
|
|
1072
|
+
catch (e) {
|
|
1073
|
+
const msg = e instanceof Error ? e.message : "Screenshot lookup failed";
|
|
1074
|
+
sendJson(res, 400, { status: "failed", error: msg });
|
|
1075
|
+
}
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
966
1078
|
if (req.method === "DELETE" && deviceAssignmentsGet) {
|
|
967
1079
|
const deviceId = decodeURIComponent(deviceAssignmentsGet[1]);
|
|
968
1080
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomorrowos/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "TomorrowOS CMS server SDK - WebSocket transport, pairing, device commands, optional static CMS UI. Includes CLI (tomorrowos init / build) and starter templates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
# Supabase/Postgres option.
|
|
2
|
-
# TOMORROWOS_STORE=supabase
|
|
3
|
-
# DATABASE_URL=postgresql://postgres:[PASSWORD]@[HOST]:5432/postgres
|
|
4
|
-
# DATABASE_SSL=true
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
# Supabase/Postgres option.
|
|
2
|
+
# TOMORROWOS_STORE=supabase
|
|
3
|
+
# DATABASE_URL=postgresql://postgres:[PASSWORD]@[HOST]:5432/postgres
|
|
4
|
+
# DATABASE_SSL=true
|
|
5
|
+
|
|
6
|
+
# Optional: override the default deterministic pairing-code secret.
|
|
7
|
+
# PAIRING_CODE_SECRET=
|
|
8
|
+
|
|
9
|
+
# Optional: upload media to Cloudinary instead of local storage.
|
|
10
|
+
# CLOUDINARY_CLOUD_NAME=
|
|
11
|
+
# CLOUDINARY_API_KEY=
|
|
12
|
+
# CLOUDINARY_API_SECRET=
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "my-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
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",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"build-player": "tomorrowos build --platform tizen"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@tomorrowos/sdk": "^0.
|
|
13
|
+
"@tomorrowos/sdk": "^0.6.1",
|
|
14
14
|
"dotenv": "^17.2.3"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
@@ -136,6 +136,18 @@
|
|
|
136
136
|
</div>
|
|
137
137
|
</div>
|
|
138
138
|
|
|
139
|
+
<div id="screenshotModal" class="modal hidden" role="dialog" aria-modal="true">
|
|
140
|
+
<div class="modal-backdrop" data-close-screenshot-modal="1"></div>
|
|
141
|
+
<div class="modal-card screenshot-modal-card">
|
|
142
|
+
<h2>Latest screenshot</h2>
|
|
143
|
+
<p class="hint" id="screenshotModalHint">No screenshot loaded.</p>
|
|
144
|
+
<img id="screenshotModalImage" class="screenshot-preview hidden" alt="Latest device screenshot" />
|
|
145
|
+
<div class="modal-actions">
|
|
146
|
+
<button type="button" data-close-screenshot-modal="1">Close</button>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
139
151
|
<script src="./methods.js" defer></script>
|
|
140
152
|
</body>
|
|
141
153
|
</html>
|
|
@@ -750,6 +750,16 @@ function renderDeviceCards() {
|
|
|
750
750
|
logsBtn.textContent = "Logs";
|
|
751
751
|
logsBtn.addEventListener("click", () => viewDeviceLogs(device.deviceId));
|
|
752
752
|
|
|
753
|
+
const screenshotBtn = document.createElement("button");
|
|
754
|
+
screenshotBtn.type = "button";
|
|
755
|
+
screenshotBtn.textContent = "Screenshot";
|
|
756
|
+
screenshotBtn.addEventListener("click", () => captureDeviceScreenshot(device.deviceId));
|
|
757
|
+
|
|
758
|
+
const latestScreenshotBtn = document.createElement("button");
|
|
759
|
+
latestScreenshotBtn.type = "button";
|
|
760
|
+
latestScreenshotBtn.textContent = "Last screen";
|
|
761
|
+
latestScreenshotBtn.addEventListener("click", () => viewLatestScreenshot(device.deviceId));
|
|
762
|
+
|
|
753
763
|
const unpairBtn = document.createElement("button");
|
|
754
764
|
unpairBtn.type = "button";
|
|
755
765
|
unpairBtn.className = "danger";
|
|
@@ -762,6 +772,8 @@ function renderDeviceCards() {
|
|
|
762
772
|
actions.appendChild(rebootBtn);
|
|
763
773
|
actions.appendChild(clearBtn);
|
|
764
774
|
actions.appendChild(logsBtn);
|
|
775
|
+
actions.appendChild(screenshotBtn);
|
|
776
|
+
actions.appendChild(latestScreenshotBtn);
|
|
765
777
|
actions.appendChild(unpairBtn);
|
|
766
778
|
|
|
767
779
|
card.appendChild(header);
|
|
@@ -1076,6 +1088,56 @@ async function viewDeviceLogs(deviceId) {
|
|
|
1076
1088
|
}
|
|
1077
1089
|
}
|
|
1078
1090
|
|
|
1091
|
+
async function captureDeviceScreenshot(deviceId) {
|
|
1092
|
+
if (!deviceId) return;
|
|
1093
|
+
const res = await fetch(`/device/${encodeURIComponent(deviceId)}/screenshot`, {
|
|
1094
|
+
method: "POST"
|
|
1095
|
+
});
|
|
1096
|
+
const data = await res.json();
|
|
1097
|
+
showResult({ deviceId, screenshot: data });
|
|
1098
|
+
if (!res.ok) {
|
|
1099
|
+
alert(data.error || "Screenshot failed");
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
openScreenshotModal(deviceId, data.screenshot);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
async function viewLatestScreenshot(deviceId) {
|
|
1106
|
+
if (!deviceId) return;
|
|
1107
|
+
const res = await fetch(`/device/${encodeURIComponent(deviceId)}/screenshot/latest`);
|
|
1108
|
+
const data = await res.json();
|
|
1109
|
+
showResult({ deviceId, latestScreenshot: data });
|
|
1110
|
+
if (!res.ok) {
|
|
1111
|
+
alert(data.error || "No screenshot available");
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
openScreenshotModal(deviceId, data.screenshot);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function openScreenshotModal(deviceId, screenshot) {
|
|
1118
|
+
const modal = document.getElementById("screenshotModal");
|
|
1119
|
+
const hint = document.getElementById("screenshotModalHint");
|
|
1120
|
+
const img = document.getElementById("screenshotModalImage");
|
|
1121
|
+
if (!modal || !hint || !img || !screenshot) return;
|
|
1122
|
+
|
|
1123
|
+
const capturedAt = formatDateTimeSeconds(screenshot.capturedAt);
|
|
1124
|
+
hint.textContent = `Device ${deviceId} — captured at ${capturedAt}`;
|
|
1125
|
+
const cacheBust = encodeURIComponent(screenshot.capturedAt || Date.now());
|
|
1126
|
+
img.src = `${screenshot.url}${screenshot.url.includes("?") ? "&" : "?"}v=${cacheBust}`;
|
|
1127
|
+
img.classList.remove("hidden");
|
|
1128
|
+
modal.classList.remove("hidden");
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function closeScreenshotModal() {
|
|
1132
|
+
const modal = document.getElementById("screenshotModal");
|
|
1133
|
+
const img = document.getElementById("screenshotModalImage");
|
|
1134
|
+
if (img) {
|
|
1135
|
+
img.removeAttribute("src");
|
|
1136
|
+
img.classList.add("hidden");
|
|
1137
|
+
}
|
|
1138
|
+
modal?.classList.add("hidden");
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1079
1141
|
function startDevicePolling() {
|
|
1080
1142
|
if (devicePollTimer) clearInterval(devicePollTimer);
|
|
1081
1143
|
void fetchDevices();
|
|
@@ -1104,6 +1166,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
1104
1166
|
document.querySelectorAll("[data-close-modal]").forEach((el) => {
|
|
1105
1167
|
el.addEventListener("click", closePublishModal);
|
|
1106
1168
|
});
|
|
1169
|
+
document.querySelectorAll("[data-close-screenshot-modal]").forEach((el) => {
|
|
1170
|
+
el.addEventListener("click", closeScreenshotModal);
|
|
1171
|
+
});
|
|
1107
1172
|
|
|
1108
1173
|
document.getElementById("addAssetBtn")?.addEventListener("click", () => {
|
|
1109
1174
|
if (uploadInProgress) return;
|
|
@@ -481,6 +481,20 @@ button.danger {
|
|
|
481
481
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
|
482
482
|
}
|
|
483
483
|
|
|
484
|
+
.screenshot-modal-card {
|
|
485
|
+
width: min(94vw, 860px);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
.screenshot-preview {
|
|
489
|
+
display: block;
|
|
490
|
+
width: 100%;
|
|
491
|
+
max-height: 62vh;
|
|
492
|
+
object-fit: contain;
|
|
493
|
+
border: 1px solid #e4e1dc;
|
|
494
|
+
border-radius: 8px;
|
|
495
|
+
background: #0a0908;
|
|
496
|
+
}
|
|
497
|
+
|
|
484
498
|
.publish-checklist label {
|
|
485
499
|
display: block;
|
|
486
500
|
margin-bottom: 0.45rem;
|