phone-lens 0.3.6 → 0.3.7

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/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { join } from "node:path";
1
+ import { basename, join, resolve, sep } from "node:path";
2
2
  import { Service } from "@deepseek-ai/cordis";
3
3
  import z from "@deepseek-ai/schemastery";
4
4
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
@@ -371,6 +371,60 @@ var DeviceStore = class {
371
371
  }
372
372
  };
373
373
 
374
+ //#endregion
375
+ //#region src/store/settings.ts
376
+ const SAVE_MODES = [
377
+ "composer",
378
+ "folder",
379
+ "both"
380
+ ];
381
+ /**
382
+ * User-facing capture settings, persisted as JSON under the plugin data dir.
383
+ * Stored host-side on purpose: the folder save happens on this machine, and
384
+ * every loopback view (overlay, /view.html) must see the same mode.
385
+ */
386
+ var AppSettingsStore = class {
387
+ data;
388
+ constructor(file, defaultSaveDir) {
389
+ this.file = file;
390
+ this.defaultSaveDir = defaultSaveDir;
391
+ this.data = {
392
+ saveMode: "composer",
393
+ saveDir: ""
394
+ };
395
+ this.load();
396
+ }
397
+ load() {
398
+ if (!existsSync(this.file)) return;
399
+ try {
400
+ const raw = JSON.parse(readFileSync(this.file, "utf8"));
401
+ if (SAVE_MODES.includes(raw.saveMode)) this.data.saveMode = raw.saveMode;
402
+ if (typeof raw.saveDir === "string") this.data.saveDir = raw.saveDir;
403
+ } catch {}
404
+ }
405
+ get() {
406
+ return { ...this.data };
407
+ }
408
+ /** Effective absolute folder (resolved default when unset). */
409
+ effectiveSaveDir() {
410
+ return this.data.saveDir.trim() || this.defaultSaveDir;
411
+ }
412
+ set(patch) {
413
+ if (patch.saveMode && SAVE_MODES.includes(patch.saveMode)) this.data.saveMode = patch.saveMode;
414
+ if (typeof patch.saveDir === "string") this.data.saveDir = patch.saveDir.replace(/^["']|["']$/g, "").trim();
415
+ this.persist();
416
+ return this.get();
417
+ }
418
+ persist() {
419
+ try {
420
+ mkdirSync(join(this.file, ".."), { recursive: true });
421
+ const tmp = `${this.file}.tmp`;
422
+ writeFileSync(tmp, JSON.stringify(this.data, null, 2), "utf8");
423
+ renameSync(tmp, this.file);
424
+ } catch {}
425
+ }
426
+ };
427
+
374
428
  //#endregion
375
429
  //#region src/types.ts
376
430
  const ERROR_CODES = {
@@ -768,6 +822,8 @@ function queryOf(req) {
768
822
 
769
823
  //#endregion
770
824
  //#region src/server/http.ts
825
+ /** Process-lifetime marker: surfaces service rebuilds via /status. */
826
+ const SERVER_BOOT_AT = Date.now();
771
827
  /** Boot the receiver: HTTP routes + two websocket endpoints. */
772
828
  async function startLensServer(deps) {
773
829
  const { config, pairing, devices, hub, targets, sink, log } = deps;
@@ -834,18 +890,19 @@ async function startLensServer(deps) {
834
890
  done(404, "Not Found");
835
891
  });
836
892
  const heartbeat = setInterval(() => hub.pingAll(), 2e4);
837
- await new Promise((resolve, reject) => {
893
+ await new Promise((resolve$1, reject) => {
838
894
  server.once("error", reject);
839
- server.listen(config.server.port, config.server.host, () => resolve());
895
+ server.listen(config.server.port, config.server.host, () => resolve$1());
840
896
  });
841
897
  log("info", `phone-lens listening on ${config.server.host}:${config.server.port} (paired devices: ${devices.count()})`);
842
898
  return {
843
899
  port: config.server.port,
844
900
  dispose: async () => {
845
901
  clearInterval(heartbeat);
902
+ hub.dispose();
846
903
  hub.detachAll();
847
904
  for (const client of wss.clients) client.terminate();
848
- await new Promise((resolve) => server.close(() => resolve()));
905
+ await new Promise((resolve$1) => server.close(() => resolve$1()));
849
906
  }
850
907
  };
851
908
  }
@@ -863,10 +920,10 @@ async function handle(deps, req, res, ctx) {
863
920
  }
864
921
  if (method === "GET" && path === "/info") return sendJson(res, 200, {
865
922
  name: "PhoneLens 直连取景",
866
- version: "0.3.5",
923
+ version: "0.3.7",
867
924
  requiresPairing: true
868
925
  }, cors);
869
- if (!loop && (path === "/" || path === "/view.html" || path === "/qr.json" || path === "/qr.png" || path === "/app-qr.json")) return sendError(res, 403, ERROR_CODES.LOOPBACK_ONLY, "preview surface is loopback-only");
926
+ if (!loop && (path === "/" || path === "/view.html" || path === "/qr.json" || path === "/qr.png" || path === "/app-qr.json" || path === "/app-settings")) return sendError(res, 403, ERROR_CODES.LOOPBACK_ONLY, "preview surface is loopback-only");
870
927
  if (method === "GET" && (path === "/" || path === "/view.html")) {
871
928
  res.writeHead(200, {
872
929
  "content-type": "text/html; charset=utf-8",
@@ -961,7 +1018,8 @@ async function handle(deps, req, res, ctx) {
961
1018
  const captureId = q.get("captureId");
962
1019
  const note = q.get("note") ?? (captureId ? hub.noteFor(captureId) : void 0);
963
1020
  if (captureId) hub.consumeCapture(captureId);
964
- const name = (q.get("name") ?? `shot_${new Date().toISOString().replace(/[:.]/g, "-")}.${mediaType === "image/png" ? "png" : "jpg"}`).slice(0, 120);
1021
+ const rawName = (q.get("name") ?? `shot_${new Date().toISOString().replace(/[:.]/g, "-")}.${mediaType === "image/png" ? "png" : "jpg"}`).slice(0, 120);
1022
+ const name = sanitizeFileName(rawName);
965
1023
  let admitted;
966
1024
  try {
967
1025
  admitted = await admitImage({
@@ -975,18 +1033,43 @@ async function handle(deps, req, res, ctx) {
975
1033
  }
976
1034
  log("info", `stored ${name} (${buf.byteLength}B) → ${admitted.storage}:${admitted.ref.attachmentId}`);
977
1035
  if (admitted.storage === "file") await pruneUploads(deps.fallbackDir, config.limits.maxStoredUploads).catch((error) => log("warn", `upload pruning failed: ${String(error)}`));
978
- const pendingPath = join(deps.pendingDir, `${admitted.ref.attachmentId}.jpg`);
979
- try {
980
- mkdirSync(deps.pendingDir, { recursive: true });
981
- writeFileSync(pendingPath, buf);
982
- log("info", `staged for composer: ${pendingPath}`);
983
- } catch (error) {
984
- log("warn", `staging failed: ${String(error)}`);
1036
+ const st = deps.appSettings.get();
1037
+ const wantFolder = st.saveMode !== "composer";
1038
+ const wantComposer = st.saveMode !== "folder";
1039
+ let savedDir = null;
1040
+ if (wantFolder) {
1041
+ const dir = deps.appSettings.effectiveSaveDir();
1042
+ try {
1043
+ mkdirSync(dir, { recursive: true });
1044
+ const fileName = nextAvailableName(dir, name);
1045
+ const target = resolveUnder(dir, fileName);
1046
+ if (!target) throw new Error("resolved path escapes the save directory");
1047
+ writeFileSync(target, buf);
1048
+ savedDir = dir;
1049
+ log("info", `folder-mode saved: ${target}`);
1050
+ } catch (error) {
1051
+ log("warn", `folder save failed (${String(error)}) — falling back to composer staging`);
1052
+ }
985
1053
  }
986
- hub.broadcastToViews({
987
- type: "pending_image",
988
- attachmentId: admitted.ref.attachmentId,
989
- name
1054
+ if (wantComposer || !savedDir) {
1055
+ const pendingPath = resolveUnder(deps.pendingDir, pendingFileName(admitted.ref.attachmentId));
1056
+ try {
1057
+ if (!pendingPath) throw new Error("resolved path escapes the pending directory");
1058
+ mkdirSync(deps.pendingDir, { recursive: true });
1059
+ writeFileSync(pendingPath, buf);
1060
+ log("info", `staged for composer: ${pendingPath}`);
1061
+ } catch (error) {
1062
+ log("warn", `staging failed: ${String(error)}`);
1063
+ }
1064
+ hub.broadcastToViews({
1065
+ type: "pending_image",
1066
+ attachmentId: admitted.ref.attachmentId,
1067
+ name
1068
+ });
1069
+ } else hub.broadcastToViews({
1070
+ type: "upload_saved",
1071
+ name,
1072
+ dir: savedDir
990
1073
  });
991
1074
  return sendJson(res, 200, {
992
1075
  ok: true,
@@ -996,12 +1079,13 @@ async function handle(deps, req, res, ctx) {
996
1079
  bytes: admitted.ref.bytes,
997
1080
  storage: admitted.storage,
998
1081
  delivered: null,
999
- deliverReason: "staged-in-composer"
1082
+ deliverReason: savedDir ? wantComposer ? "saved-and-staged" : "saved-to-folder" : "staged-in-composer"
1000
1083
  });
1001
1084
  }
1002
1085
  if (method === "GET" && path.startsWith("/pending/") && loop) {
1003
1086
  const id = path.slice(9).split("/")[0] ?? "";
1004
- const file = join(deps.pendingDir, `${id}.jpg`);
1087
+ const file = resolveUnder(deps.pendingDir, pendingFileName(id));
1088
+ if (!file) return sendError(res, 404, ERROR_CODES.BAD_REQUEST, "pending image not found");
1005
1089
  try {
1006
1090
  const data = await readFile(file);
1007
1091
  res.writeHead(200, {
@@ -1018,6 +1102,7 @@ async function handle(deps, req, res, ctx) {
1018
1102
  if (method === "GET" && path === "/status") {
1019
1103
  const cameraDeviceId = ctx.getCurrentCamera();
1020
1104
  return sendJson(res, 200, {
1105
+ bootAt: SERVER_BOOT_AT,
1021
1106
  devices: devices.list().map((d) => ({
1022
1107
  id: d.deviceId,
1023
1108
  name: d.name,
@@ -1039,6 +1124,28 @@ async function handle(deps, req, res, ctx) {
1039
1124
  targets: targets.list(),
1040
1125
  default: targets.resolve()?.sessionId ?? null
1041
1126
  }, cors);
1127
+ if (method === "GET" && path === "/app-settings") {
1128
+ const st = deps.appSettings.get();
1129
+ return sendJson(res, 200, {
1130
+ saveMode: st.saveMode,
1131
+ saveDir: st.saveDir,
1132
+ effectiveSaveDir: deps.appSettings.effectiveSaveDir()
1133
+ }, cors);
1134
+ }
1135
+ if (method === "POST" && path === "/app-settings") {
1136
+ const body = await readJsonBody(req, 4096);
1137
+ if (!body) return sendError(res, 400, ERROR_CODES.BAD_REQUEST, "invalid JSON body");
1138
+ const patch = {};
1139
+ if (typeof body.saveMode === "string" && SAVE_MODES.includes(body.saveMode)) patch.saveMode = body.saveMode;
1140
+ if (typeof body.saveDir === "string") patch.saveDir = body.saveDir;
1141
+ const st = deps.appSettings.set(patch);
1142
+ log("info", `app settings updated: mode=${st.saveMode} dir=${st.saveDir ? JSON.stringify(st.saveDir) : "(default)"}`);
1143
+ return sendJson(res, 200, {
1144
+ saveMode: st.saveMode,
1145
+ saveDir: st.saveDir,
1146
+ effectiveSaveDir: deps.appSettings.effectiveSaveDir()
1147
+ }, cors);
1148
+ }
1042
1149
  if (method === "POST" && path === "/capture" && loop) {
1043
1150
  const captureId = randomUUID();
1044
1151
  const ok = hub.requestCapture(captureId, queryOf(req).get("note") ?? void 0);
@@ -1047,6 +1154,37 @@ async function handle(deps, req, res, ctx) {
1047
1154
  }
1048
1155
  return sendError(res, 404, ERROR_CODES.BAD_REQUEST, `no route ${method} ${path}`);
1049
1156
  }
1157
+ /** Strip path separators / reserved characters so a phone-supplied upload
1158
+ * name can never escape the target directory or hide as a dotfile. */
1159
+ function sanitizeFileName(input) {
1160
+ const base = basename(input).replace(/[<>:"|?*\u0000-\u001F]/g, "_").replace(/^[\s.]+/, "").trim();
1161
+ return base || "shot.jpg";
1162
+ }
1163
+ /** Pick `name`, then `name (2).ext`, `name (3).ext`, … — folder saves never overwrite. */
1164
+ function nextAvailableName(dir, name) {
1165
+ if (!existsSync(join(dir, name))) return name;
1166
+ const dot = name.lastIndexOf(".");
1167
+ const stem = dot > 0 ? name.slice(0, dot) : name;
1168
+ const ext = dot > 0 ? name.slice(dot) : "";
1169
+ for (let n = 2;; n++) {
1170
+ const candidate = `${stem} (${n})${ext}`;
1171
+ if (!existsSync(join(dir, candidate))) return candidate;
1172
+ }
1173
+ }
1174
+ /** Pending-staging filename for an attachment id. Ids may be URN-ish
1175
+ * ("file:<digest>" without the dsh attachment service) and Windows forbids
1176
+ * ":" in filenames — flatten to a portable name, write and read alike. */
1177
+ function pendingFileName(attachmentId) {
1178
+ return `${attachmentId.replace(/[^A-Za-z0-9._-]/g, "_")}.jpg`;
1179
+ }
1180
+ /** Resolve `name` under `baseDir` and refuse anything that escapes it
1181
+ * (defense in depth on top of sanitizeFileName — e.g. a crafted name that
1182
+ * survives sanitization but still resolves outside the base). */
1183
+ function resolveUnder(baseDir, name) {
1184
+ const base = resolve(baseDir);
1185
+ const target = resolve(base, name);
1186
+ return target === base || target.startsWith(base + sep) ? target : null;
1187
+ }
1050
1188
  function sendJson(res, status, body, extraHeaders) {
1051
1189
  res.writeHead(status, {
1052
1190
  "content-type": "application/json; charset=utf-8",
@@ -1072,7 +1210,7 @@ async function readJsonBody(req, limit) {
1072
1210
  }
1073
1211
  }
1074
1212
  function readRawBody(req, maxBytes) {
1075
- return new Promise((resolve, reject) => {
1213
+ return new Promise((resolve$1, reject) => {
1076
1214
  const chunks = [];
1077
1215
  let size = 0;
1078
1216
  let truncated = false;
@@ -1082,7 +1220,7 @@ function readRawBody(req, maxBytes) {
1082
1220
  truncated = true;
1083
1221
  chunks.length = 0;
1084
1222
  req.destroy();
1085
- resolve({
1223
+ resolve$1({
1086
1224
  buf: Buffer.alloc(0),
1087
1225
  truncated
1088
1226
  });
@@ -1090,7 +1228,7 @@ function readRawBody(req, maxBytes) {
1090
1228
  }
1091
1229
  chunks.push(chunk);
1092
1230
  });
1093
- req.on("end", () => resolve({
1231
+ req.on("end", () => resolve$1({
1094
1232
  buf: Buffer.concat(chunks),
1095
1233
  truncated
1096
1234
  }));
@@ -1122,6 +1260,22 @@ async function pruneUploads(dir, max) {
1122
1260
  //#region src/server/hub.ts
1123
1261
  /**
1124
1262
 
1263
+ * How long the ACTIVE device's stream may go silent before views are told the
1264
+
1265
+ * preview was turned off. The phone app never announces "preview off" — it
1266
+
1267
+ * just stops pushing frames (the WS stays up) — so the host derives the state:
1268
+
1269
+ * a live, open camera uplink with no frames for this long means the user
1270
+
1271
+ * switched preview off on the phone. Device disconnects are signalled
1272
+
1273
+ * separately via the `devices` list.
1274
+
1275
+ */
1276
+ const PREVIEW_STALL_MS = 3e3;
1277
+ /**
1278
+
1125
1279
  * The viewfinder hub: MULTIPLE camera uplinks (one per phone, keyed by
1126
1280
 
1127
1281
  * deviceId), N loopback view downlinks. The "active" device is auto-selected
@@ -1142,13 +1296,23 @@ var ViewHub = class {
1142
1296
  /** captureId → { note, requestedAt } until the matching upload lands or timeout. */
1143
1297
  pendingCaptures = new Map();
1144
1298
  captureTimeoutMs = 6e4;
1299
+ /** Whether the active device is confirmed to be streaming (hello/frame seen). */
1300
+ previewOn = false;
1301
+ stallTimer = null;
1145
1302
  constructor(config, log) {
1146
1303
  this.config = config;
1147
1304
  this.log = log;
1305
+ this.stallTimer = setInterval(() => this.checkPreviewStall(), 1e3);
1306
+ }
1307
+ /** Stop the stall watchdog (server dispose). */
1308
+ dispose() {
1309
+ if (this.stallTimer) clearInterval(this.stallTimer);
1310
+ this.stallTimer = null;
1148
1311
  }
1149
1312
  attachCamera(deviceId, ws, name) {
1150
1313
  const prev = this.cameras.get(deviceId);
1151
1314
  if (prev) {
1315
+ this.log("warn", `camera re-attach: kicking previous ws for ${deviceId.slice(0, 8)}`);
1152
1316
  try {
1153
1317
  prev.ws.close(1e3, "new-instance");
1154
1318
  } catch {}
@@ -1169,7 +1333,8 @@ var ViewHub = class {
1169
1333
  if (this.activeDeviceId === deviceId) this.sendControl(deviceId, { type: "resume_preview" });
1170
1334
  else this.sendControl(deviceId, { type: "pause_preview" });
1171
1335
  this.log("info", `camera uplink: ${name} (${deviceId.slice(0, 8)})`);
1172
- ws.on("close", () => {
1336
+ ws.on("close", (code, reason) => {
1337
+ this.log("warn", `camera ws closed: code=${code} reason=${reason.toString("utf8") || "-"} (${name} ${deviceId.slice(0, 8)})`);
1173
1338
  if (this.cameras.get(deviceId)?.ws === ws) this.detachCamera(deviceId);
1174
1339
  });
1175
1340
  ws.on("message", (data, isBinary) => {
@@ -1183,6 +1348,7 @@ var ViewHub = class {
1183
1348
  }
1184
1349
  detachCamera(deviceId) {
1185
1350
  const cam = this.cameras.get(deviceId);
1351
+ this.log("warn", `detachCamera(${deviceId.slice(0, 8)}) had-camera=${!!cam}`);
1186
1352
  if (cam) this.cameras.delete(deviceId);
1187
1353
  if (this.activeDeviceId === deviceId) {
1188
1354
  const next = [...this.cameras.keys()].at(-1) ?? null;
@@ -1215,14 +1381,18 @@ var ViewHub = class {
1215
1381
  fps: msg.fps,
1216
1382
  ...msg.rotation !== void 0 ? { rotation: msg.rotation } : {}
1217
1383
  };
1218
- if (this.activeDeviceId === deviceId) this.broadcastToViews({
1219
- type: "frame_meta",
1220
- width: msg.width,
1221
- height: msg.height,
1222
- ...msg.rotation !== void 0 ? { rotation: msg.rotation } : {}
1223
- });
1384
+ if (this.activeDeviceId === deviceId) {
1385
+ this.markPreviewActive();
1386
+ this.broadcastToViews({
1387
+ type: "frame_meta",
1388
+ width: msg.width,
1389
+ height: msg.height,
1390
+ ...msg.rotation !== void 0 ? { rotation: msg.rotation } : {}
1391
+ });
1392
+ }
1224
1393
  break;
1225
1394
  case "bye":
1395
+ this.log("warn", `camera sent bye: ${deviceId.slice(0, 8)}`);
1226
1396
  this.detachCamera(deviceId);
1227
1397
  break;
1228
1398
  case "claim_active":
@@ -1258,12 +1428,50 @@ var ViewHub = class {
1258
1428
  cam.frameCount = 0;
1259
1429
  }
1260
1430
  if (this.activeDeviceId === deviceId) {
1431
+ this.markPreviewActive();
1261
1432
  for (const view of this.views) if (view.readyState === view.OPEN) view.send(frame, { binary: true });
1262
1433
  }
1263
1434
  }
1435
+ /** Flip the view-side preview state to on (once) when stream activity returns. */
1436
+ markPreviewActive() {
1437
+ if (this.previewOn) return;
1438
+ this.previewOn = true;
1439
+ this.broadcastToViews({
1440
+ type: "preview_state",
1441
+ on: true
1442
+ });
1443
+ }
1444
+ /**
1445
+
1446
+ * Watchdog: an open, ACTIVE camera uplink that has gone silent means the
1447
+
1448
+ * user turned preview off on the phone (the app keeps the WS while only
1449
+
1450
+ * stopping its frame pump). Tell views so they can clear the stale frame.
1451
+
1452
+ * Disconnected devices are NOT handled here — the `devices` broadcast owns
1453
+
1454
+ * the offline state on the view side.
1455
+
1456
+ */
1457
+ checkPreviewStall() {
1458
+ if (!this.previewOn) return;
1459
+ const active = this.activeCam();
1460
+ if (!active || active.ws.readyState !== active.ws.OPEN) return;
1461
+ if (Date.now() - active.lastFrameAt <= PREVIEW_STALL_MS) return;
1462
+ this.previewOn = false;
1463
+ this.broadcastToViews({
1464
+ type: "preview_state",
1465
+ on: false
1466
+ });
1467
+ this.log("info", `active preview stalled >${PREVIEW_STALL_MS}ms — notified views (preview off)`);
1468
+ }
1264
1469
  attachView(ws, hooks = {}) {
1265
1470
  this.views.add(ws);
1266
- ws.on("close", () => this.views.delete(ws));
1471
+ ws.on("close", (code, reason) => {
1472
+ this.log("warn", `view ws closed: code=${code} reason=${reason.toString("utf8") || "-"} (views left: ${this.views.size - 1})`);
1473
+ this.views.delete(ws);
1474
+ });
1267
1475
  ws.on("message", (data, isBinary) => {
1268
1476
  if (isBinary) return;
1269
1477
  const msg = parseViewClient(data.toString());
@@ -1291,7 +1499,11 @@ var ViewHub = class {
1291
1499
  paired: true
1292
1500
  }));
1293
1501
  this.broadcastDevicesTo(ws);
1294
- if (active?.lastFrame && ws.readyState === ws.OPEN) ws.send(active.lastFrame, { binary: true });
1502
+ ws.send(JSON.stringify({
1503
+ type: "preview_state",
1504
+ on: this.previewOn
1505
+ }));
1506
+ if (this.previewOn && active?.lastFrame && ws.readyState === ws.OPEN) ws.send(active.lastFrame, { binary: true });
1295
1507
  }
1296
1508
  viewCount() {
1297
1509
  return this.views.size;
@@ -1320,7 +1532,7 @@ var ViewHub = class {
1320
1532
  }
1321
1533
  pushActiveFrameToViews() {
1322
1534
  const active = this.activeCam();
1323
- if (active?.lastFrame) {
1535
+ if (this.previewOn && active?.lastFrame) {
1324
1536
  for (const view of this.views) if (view.readyState === view.OPEN) view.send(active.lastFrame, { binary: true });
1325
1537
  }
1326
1538
  if (active) this.broadcastToViews({
@@ -1450,6 +1662,7 @@ var PhoneLens = class extends Service {
1450
1662
  };
1451
1663
  const pairing = new PairingStore(config.pairing.codeTtlMs);
1452
1664
  const devices = new DeviceStore(dataDir);
1665
+ const appSettings = new AppSettingsStore(join(dataDir, "settings.json"), join(dataDir, "saved"));
1453
1666
  const hub = new ViewHub(config, (level, msg) => log(level, msg));
1454
1667
  const targets = new TargetTracker(config);
1455
1668
  const sink = new HostDeliverySink(config, (level, msg) => log(level, msg));
@@ -1473,6 +1686,7 @@ var PhoneLens = class extends Service {
1473
1686
  attachments,
1474
1687
  fallbackDir: join(dataDir, "uploads"),
1475
1688
  pendingDir: join(dataDir, "pending"),
1689
+ appSettings,
1476
1690
  log
1477
1691
  }).then(async (h) => {
1478
1692
  handle$1 = h;