@tomorrowos/sdk 0.6.0 → 0.6.2
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 +1 -1
- package/dist/index.d.ts.map +1 -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 +108 -0
- package/package.json +1 -1
- package/templates/cms-starter/.env.example +12 -12
- package/templates/cms-starter/package.json +2 -2
- package/templates/cms-starter/public/index.html +12 -0
- package/templates/cms-starter/public/methods.js +66 -0
- package/templates/cms-starter/public/panel.css +14 -0
package/dist/tomorrowos.js
CHANGED
|
@@ -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/"))
|
|
@@ -577,7 +580,9 @@ export class TomorrowOS extends EventEmitter {
|
|
|
577
580
|
}
|
|
578
581
|
this.staticIndexFile = idx;
|
|
579
582
|
const uploadsDir = path.join(path.resolve(this.staticRoot), "uploads");
|
|
583
|
+
const screenshotsDir = path.join(path.resolve(this.staticRoot), "screenshots");
|
|
580
584
|
void fs.mkdir(uploadsDir, { recursive: true });
|
|
585
|
+
void fs.mkdir(screenshotsDir, { recursive: true });
|
|
581
586
|
void this.refreshResolvedBrandLogo();
|
|
582
587
|
}
|
|
583
588
|
else {
|
|
@@ -821,6 +826,79 @@ export class TomorrowOS extends EventEmitter {
|
|
|
821
826
|
return false;
|
|
822
827
|
}
|
|
823
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.captureScreen", {});
|
|
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
|
+
}
|
|
824
902
|
async handleHttp(req, res) {
|
|
825
903
|
try {
|
|
826
904
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
@@ -967,6 +1045,36 @@ export class TomorrowOS extends EventEmitter {
|
|
|
967
1045
|
sendJson(res, 200, { status: "success", logs: this.getDeviceLogs(deviceId) });
|
|
968
1046
|
return;
|
|
969
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
|
+
}
|
|
970
1078
|
if (req.method === "DELETE" && deviceAssignmentsGet) {
|
|
971
1079
|
const deviceId = decodeURIComponent(deviceAssignmentsGet[1]);
|
|
972
1080
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomorrowos/sdk",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
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,12 +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
|
-
# 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
|
+
# 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.6.
|
|
3
|
+
"version": "0.6.2",
|
|
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.6.
|
|
13
|
+
"@tomorrowos/sdk": "^0.6.2",
|
|
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,57 @@ 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
|
+
const capturedAt = formatDateTimeSeconds(data.screenshot?.capturedAt);
|
|
1103
|
+
alert(`Screenshot captured successfully${capturedAt ? ` at ${capturedAt}` : ""}.`);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
async function viewLatestScreenshot(deviceId) {
|
|
1107
|
+
if (!deviceId) return;
|
|
1108
|
+
const res = await fetch(`/device/${encodeURIComponent(deviceId)}/screenshot/latest`);
|
|
1109
|
+
const data = await res.json();
|
|
1110
|
+
showResult({ deviceId, latestScreenshot: data });
|
|
1111
|
+
if (!res.ok) {
|
|
1112
|
+
alert(data.error || "No screenshot available");
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
openScreenshotModal(deviceId, data.screenshot);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function openScreenshotModal(deviceId, screenshot) {
|
|
1119
|
+
const modal = document.getElementById("screenshotModal");
|
|
1120
|
+
const hint = document.getElementById("screenshotModalHint");
|
|
1121
|
+
const img = document.getElementById("screenshotModalImage");
|
|
1122
|
+
if (!modal || !hint || !img || !screenshot) return;
|
|
1123
|
+
|
|
1124
|
+
const capturedAt = formatDateTimeSeconds(screenshot.capturedAt);
|
|
1125
|
+
hint.textContent = `Device ${deviceId} — captured at ${capturedAt}`;
|
|
1126
|
+
const cacheBust = encodeURIComponent(screenshot.capturedAt || Date.now());
|
|
1127
|
+
img.src = `${screenshot.url}${screenshot.url.includes("?") ? "&" : "?"}v=${cacheBust}`;
|
|
1128
|
+
img.classList.remove("hidden");
|
|
1129
|
+
modal.classList.remove("hidden");
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function closeScreenshotModal() {
|
|
1133
|
+
const modal = document.getElementById("screenshotModal");
|
|
1134
|
+
const img = document.getElementById("screenshotModalImage");
|
|
1135
|
+
if (img) {
|
|
1136
|
+
img.removeAttribute("src");
|
|
1137
|
+
img.classList.add("hidden");
|
|
1138
|
+
}
|
|
1139
|
+
modal?.classList.add("hidden");
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1079
1142
|
function startDevicePolling() {
|
|
1080
1143
|
if (devicePollTimer) clearInterval(devicePollTimer);
|
|
1081
1144
|
void fetchDevices();
|
|
@@ -1104,6 +1167,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
1104
1167
|
document.querySelectorAll("[data-close-modal]").forEach((el) => {
|
|
1105
1168
|
el.addEventListener("click", closePublishModal);
|
|
1106
1169
|
});
|
|
1170
|
+
document.querySelectorAll("[data-close-screenshot-modal]").forEach((el) => {
|
|
1171
|
+
el.addEventListener("click", closeScreenshotModal);
|
|
1172
|
+
});
|
|
1107
1173
|
|
|
1108
1174
|
document.getElementById("addAssetBtn")?.addEventListener("click", () => {
|
|
1109
1175
|
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;
|