@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.
@@ -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.0",
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.0",
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.0",
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;