dsh-plugin-notify-sounds 1.8.6 → 1.9.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.
Files changed (5) hide show
  1. package/README.md +209 -205
  2. package/README.zh.md +208 -204
  3. package/lib/client.js +3285 -2794
  4. package/lib/index.js +275 -6
  5. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * All behavior lives in the browser entry (`./client`): the sounds themselves
5
5
  * are Web Audio synthesis, and the triggers are the Client's own
6
6
  * `uiSession.sessionStatus` facts (pending interactions and running→idle
7
- * transitions). This Node half owns the four things the browser cannot:
7
+ * transitions). This Node half owns the five things the browser cannot:
8
8
  *
9
9
  * - the volatile Config schema below is what the Host Settings document
10
10
  * serves under the `notify-sounds` namespace, so the Settings section the
@@ -19,7 +19,11 @@
19
19
  * manager, so the row reports and points, and never writes a file;
20
20
  * - `/notify-sounds/goal` answers one session's goal phase from the Host's
21
21
  * authoritative projection. The browser only carries sessions it has
22
- * opened, and a goal-mode completion edge can belong to one it has not.
22
+ * opened, and a goal-mode completion edge can belong to one it has not;
23
+ * - `/notify-sounds/sound` is the uploaded-sound store: the page reads a
24
+ * stored clip by content hash, posts a new one, or deletes a clip nothing
25
+ * references. It is the only place this plugin writes a file, and it only
26
+ * ever touches its own directory under the profile.
23
27
  *
24
28
  * `apply` also asks the settings namespace to hold off any auto-generated
25
29
  * inventory page for this entry, because the Client contributes a dedicated
@@ -32,8 +36,9 @@
32
36
 
33
37
  import z from "@deepseek-ai/schemastery";
34
38
  import { spawn } from "node:child_process";
39
+ import { createHash } from "node:crypto";
35
40
  import { createRequire } from "node:module";
36
- import { existsSync, readFileSync } from "node:fs";
41
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
37
42
  import { basename, dirname, join } from "node:path";
38
43
  import { fileURLToPath } from "node:url";
39
44
 
@@ -41,7 +46,7 @@ import { fileURLToPath } from "node:url";
41
46
  export const name = "notify-sounds";
42
47
 
43
48
  /** Sound ids shared with the Client half's preset table (spelled, not imported: the browser bundle is self-contained). Exported so the tests can cross-check the two spellings. */
44
- export const SOUND_IDS = ["bell", "chime", "ding", "drop", "pulse", "arc", "coin", "success", "knock", "custom", "none"];
49
+ export const SOUND_IDS = ["bell", "chime", "ding", "drop", "pulse", "arc", "coin", "success", "knock", "custom1", "custom2", "custom3", "custom", "none"];
45
50
 
46
51
  /** Quiet-hours time-of-day pattern, "HH:MM". Exported for the same cross-check. */
47
52
  export const TIME_OF_DAY = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
@@ -690,6 +695,154 @@ async function readCappedBytes(response, cap) {
690
695
  return merged;
691
696
  }
692
697
 
698
+ /**
699
+ * Route the page uses to read and write uploaded sounds. Exported so the
700
+ * tests can cross-check the Client half's spelling.
701
+ */
702
+ export const SOUND_PATH = "/notify-sounds/sound";
703
+
704
+ /** Largest uploaded sound accepted, in bytes. */
705
+ export const SOUND_MAX_BYTES = 3 * 1024 * 1024;
706
+
707
+ /**
708
+ * The only file names the sound directory may contain: a lowercase sha256 and
709
+ * a short extension. Anything else is refused outright rather than cleaned, so
710
+ * a hand-edited configuration cannot walk out of the directory.
711
+ */
712
+ const SOUND_FILE_PATTERN = /^[a-f0-9]{64}\.(?:mp3|wav|ogg|m4a|flac)$/;
713
+
714
+ /** Directory under the profile that holds uploaded sounds. */
715
+ const SOUND_DIRECTORY = "notify-sounds";
716
+
717
+ /** Container formats this store accepts, identified by magic bytes. */
718
+ const SOUND_FORMATS = [
719
+ { extension: "flac", mime: "audio/flac", matches: (b) => b[0] === 0x66 && b[1] === 0x4c && b[2] === 0x61 && b[3] === 0x43 },
720
+ { extension: "ogg", mime: "audio/ogg", matches: (b) => b[0] === 0x4f && b[1] === 0x67 && b[2] === 0x67 && b[3] === 0x53 },
721
+ {
722
+ extension: "wav",
723
+ mime: "audio/wav",
724
+ matches: (b) => b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x41 && b[10] === 0x56 && b[11] === 0x45,
725
+ },
726
+ { extension: "m4a", mime: "audio/mp4", matches: (b) => b[4] === 0x66 && b[5] === 0x74 && b[6] === 0x79 && b[7] === 0x70 },
727
+ {
728
+ extension: "mp3",
729
+ mime: "audio/mpeg",
730
+ matches: (b) => (b[0] === 0x49 && b[1] === 0x44 && b[2] === 0x33) || (b[0] === 0xff && (b[1] & 0xe0) === 0xe0),
731
+ },
732
+ ];
733
+
734
+ /**
735
+ * The uploaded-sound directory for this installation, or undefined when there
736
+ * is no profile (a development checkout): then nothing is stored and the page
737
+ * keeps using the legacy inline payload.
738
+ *
739
+ * @returns the absolute directory, or undefined.
740
+ */
741
+ function soundsDirectory() {
742
+ const profile = profileDirectory();
743
+ return profile === undefined ? undefined : join(profile, SOUND_DIRECTORY, "sounds");
744
+ }
745
+
746
+ /**
747
+ * Resolve one stored sound by name, refusing anything that is not a plain
748
+ * digest-plus-extension inside the directory.
749
+ *
750
+ * @param fileName - the name the page asked for.
751
+ * @returns the absolute path, or undefined when the name is not one of ours.
752
+ */
753
+ export function soundFilePath(fileName) {
754
+ if (typeof fileName !== "string" || !SOUND_FILE_PATTERN.test(fileName)) return undefined;
755
+ const directory = soundsDirectory();
756
+ return directory === undefined ? undefined : join(directory, fileName);
757
+ }
758
+
759
+ /**
760
+ * Write one uploaded sound and return the name it was stored under.
761
+ *
762
+ * The name is the sha256 of the bytes, so the same audio uploaded twice — or
763
+ * migrated twice — lands on the same file and nothing has to be reconciled.
764
+ * A partially written file is never visible: the bytes go to `<name>.tmp` and
765
+ * are renamed into place, and an existing target is left untouched because its
766
+ * content is by definition already identical.
767
+ *
768
+ * @param bytes - the uploaded audio.
769
+ * @returns the stored name, or undefined when the bytes are not audio this
770
+ * store accepts (or there is no profile to store them in).
771
+ */
772
+ export function writeSoundFile(bytes) {
773
+ try {
774
+ const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
775
+ if (buffer.length === 0 || buffer.length > SOUND_MAX_BYTES) return undefined;
776
+ const format = SOUND_FORMATS.find((candidate) => candidate.matches(buffer));
777
+ if (format === undefined) return undefined;
778
+ const directory = soundsDirectory();
779
+ if (directory === undefined) return undefined;
780
+ const fileName = `${createHash("sha256").update(buffer).digest("hex")}.${format.extension}`;
781
+ const target = join(directory, fileName);
782
+ if (existsSync(target)) return fileName;
783
+ mkdirSync(directory, { recursive: true });
784
+ const temporary = `${target}.tmp`;
785
+ writeFileSync(temporary, buffer);
786
+ renameSync(temporary, target);
787
+ return fileName;
788
+ } catch (error) {
789
+ // A read-only profile or a full disk must not take the route down: the
790
+ // caller reports "not stored" and the page keeps whatever it had.
791
+ warn(`sound write failed: ${error?.message ?? error}`);
792
+ return undefined;
793
+ }
794
+ }
795
+
796
+ /**
797
+ * Delete one stored sound, ignoring a name that is not ours and a file that is
798
+ * already gone.
799
+ *
800
+ * @param fileName - the name to remove.
801
+ */
802
+ export function deleteSoundFile(fileName) {
803
+ const path = soundFilePath(fileName);
804
+ if (path === undefined) return;
805
+ try {
806
+ rmSync(path, { force: true });
807
+ } catch (error) {
808
+ warn(`sound delete failed: ${error?.message ?? error}`);
809
+ }
810
+ }
811
+
812
+
813
+ /**
814
+ * The MIME type for a stored name.
815
+ *
816
+ * @param fileName - a name already accepted by `soundFilePath`.
817
+ * @returns the content type to serve it with.
818
+ */
819
+ export function soundMimeType(fileName) {
820
+ const extension = fileName.slice(fileName.lastIndexOf(".") + 1);
821
+ return SOUND_FORMATS.find((format) => format.extension === extension)?.mime ?? "application/octet-stream";
822
+ }
823
+
824
+ /**
825
+ * Read a request body no larger than `limit`.
826
+ *
827
+ * The stream is abandoned the moment the cap is crossed rather than read to
828
+ * the end: an oversized upload is a mistake or an attack, and neither deserves
829
+ * the bandwidth.
830
+ *
831
+ * @param req - the incoming request.
832
+ * @param limit - the largest body accepted.
833
+ * @returns the body, or undefined when it is too large.
834
+ */
835
+ async function readRequestBytes(req, limit) {
836
+ const chunks = [];
837
+ let total = 0;
838
+ for await (const chunk of req) {
839
+ total += chunk.length;
840
+ if (total > limit) return undefined;
841
+ chunks.push(chunk);
842
+ }
843
+ return Buffer.concat(chunks);
844
+ }
845
+
693
846
  /**
694
847
  * The profile directory that owns this installation, when there is one.
695
848
  *
@@ -898,10 +1051,42 @@ export const Config = z.object({
898
1051
  updateAutoCheck: z.boolean().default(true).volatile(),
899
1052
  /** Play only while the page is not visible (background tab or window). */
900
1053
  onlyWhenHidden: z.boolean().default(false).volatile(),
901
- /** Uploaded audio for the `custom` preset, as base64; empty when none. */
1054
+ /**
1055
+ * The user's own sounds: up to three, each a stored file plus the name it
1056
+ * is shown under. A trigger picks one by slot id (`custom1`…`custom3`).
1057
+ *
1058
+ * Slots are FIXED, not compacted: deleting the second sound leaves slot 2
1059
+ * empty rather than shifting slot 3 into it, so a trigger pointing at
1060
+ * `custom3` keeps meaning the same recording.
1061
+ */
1062
+ custom1File: z.string().default("").volatile(),
1063
+ custom1Name: z.string().default("").volatile(),
1064
+ custom2File: z.string().default("").volatile(),
1065
+ custom2Name: z.string().default("").volatile(),
1066
+ custom3File: z.string().default("").volatile(),
1067
+ custom3Name: z.string().default("").volatile(),
1068
+ /**
1069
+ * Legacy inline audio (up to 1.8.6), base64; empty when none.
1070
+ *
1071
+ * Kept readable so an install that updated itself from an older release
1072
+ * keeps its sound: the browser migrates it into slot 1 on first run and
1073
+ * only then clears it. Nothing writes this field any more.
1074
+ */
902
1075
  customSound: z.string().default("").volatile(),
903
- /** Original file name of the uploaded custom sound, for display. */
1076
+ /** Legacy display name that belonged to `customSound`. */
904
1077
  customSoundName: z.string().default("").volatile(),
1078
+ /** Legacy per-trigger reference from 1.9.0, migrated into the list then cleared. */
1079
+ customDecisionFile: z.string().default("").volatile(),
1080
+ /** Legacy display name that belonged to `customDecisionFile`. */
1081
+ customDecisionName: z.string().default("").volatile(),
1082
+ /** Legacy per-trigger reference from 1.9.0. */
1083
+ customQuestionFile: z.string().default("").volatile(),
1084
+ /** Legacy display name that belonged to `customQuestionFile`. */
1085
+ customQuestionName: z.string().default("").volatile(),
1086
+ /** Legacy per-trigger reference from 1.9.0. */
1087
+ customCompletionFile: z.string().default("").volatile(),
1088
+ /** Legacy display name that belonged to `customCompletionFile`. */
1089
+ customCompletionName: z.string().default("").volatile(),
905
1090
  /** Show an OS notification (when the page is hidden) in addition to the sound. */
906
1091
  systemNotify: z.boolean().default(false).volatile(),
907
1092
  /** Prefix the window title with a word (待处理 / 已完成) while the page is hidden. */
@@ -1077,4 +1262,88 @@ export function apply(ctx) {
1077
1262
  },
1078
1263
  }), "notify-sounds: goal phase route");
1079
1264
  });
1265
+
1266
+ // Uploaded sounds (1.9.0): the bytes live as files beside the profile, not
1267
+ // inside the configuration, and the page asks for them by content hash.
1268
+ //
1269
+ // This is deliberately STATELESS: the Host does not read its own config
1270
+ // here. Volatile config values are live cells rather than plain strings,
1271
+ // so a Host-side read would have to unwrap them — and the browser already
1272
+ // holds the authoritative snapshot. The page therefore names the file it
1273
+ // wants (`?file=<sha>.<ext>`), and the Host only ever touches the one
1274
+ // directory it owns, after checking the name against a hash pattern.
1275
+ ctx.inject(["webServer", "connection"], (child) => {
1276
+ child.effect(() => child.webServer.register({
1277
+ kind: "exact",
1278
+ path: SOUND_PATH,
1279
+ handler: async (req, res) => {
1280
+ const rejection = child.connection.requestRejection(req);
1281
+ if (rejection !== undefined) {
1282
+ res.statusCode = rejection;
1283
+ res.end();
1284
+ return;
1285
+ }
1286
+ const url = new URL(req.url ?? "/", "http://localhost");
1287
+ try {
1288
+ if (req.method === "GET" || req.method === "HEAD") {
1289
+ const name = url.searchParams.get("file") ?? "";
1290
+ const file = soundFilePath(name);
1291
+ // Read, do not stat: one call closes the window between
1292
+ // "the file exists" and "here are its bytes".
1293
+ let bytes;
1294
+ try {
1295
+ bytes = file === undefined ? undefined : readFileSync(file);
1296
+ } catch {
1297
+ bytes = undefined;
1298
+ }
1299
+ if (bytes === undefined) {
1300
+ res.statusCode = 404;
1301
+ res.end();
1302
+ return;
1303
+ }
1304
+ res.statusCode = 200;
1305
+ // Content-addressed: the name IS the digest, so the bytes
1306
+ // behind it can never change.
1307
+ res.setHeader("cache-control", "public, max-age=31536000, immutable");
1308
+ res.setHeader("content-type", soundMimeType(name));
1309
+ res.setHeader("content-length", String(bytes.length));
1310
+ res.end(req.method === "HEAD" ? undefined : bytes);
1311
+ return;
1312
+ }
1313
+ if (req.method === "POST") {
1314
+ const bytes = await readRequestBytes(req, SOUND_MAX_BYTES);
1315
+ if (bytes === undefined) {
1316
+ res.statusCode = 413;
1317
+ res.end();
1318
+ return;
1319
+ }
1320
+ const name = writeSoundFile(bytes);
1321
+ if (name === undefined) {
1322
+ res.statusCode = 415;
1323
+ res.end();
1324
+ return;
1325
+ }
1326
+ res.statusCode = 200;
1327
+ res.setHeader("content-type", "application/json");
1328
+ res.end(JSON.stringify({ file: name }));
1329
+ return;
1330
+ }
1331
+ if (req.method === "DELETE") {
1332
+ const name = url.searchParams.get("file") ?? "";
1333
+ deleteSoundFile(name);
1334
+ res.statusCode = 204;
1335
+ res.end();
1336
+ return;
1337
+ }
1338
+ res.statusCode = 405;
1339
+ res.setHeader("allow", "GET, POST, DELETE");
1340
+ res.end();
1341
+ } catch (error) {
1342
+ warn(`sound store failed: ${error?.message ?? error}`);
1343
+ res.statusCode = 500;
1344
+ res.end();
1345
+ }
1346
+ },
1347
+ }), "notify-sounds: sound store route");
1348
+ });
1080
1349
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-notify-sounds",
3
- "version": "1.8.6",
3
+ "version": "1.9.0",
4
4
  "description": "DeepSeek Harness 原生风格提示音插件:需要决策或任务完成时播放提示音,点击系统通知可回到窗口;支持重复提醒、静音时段,以及目标模式下只在最终轮提醒。 · Native-style notification sounds for DeepSeek Harness: a chime when the agent needs your decision or finishes a task, click a system notification to jump back, plus repeat reminders, quiet hours, and goal-mode alerts that wait for the final round.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",