dsh-plugin-notify-sounds 1.8.6 → 1.9.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/README.md +211 -205
- package/README.zh.md +210 -204
- package/lib/client.js +3475 -2794
- package/lib/index.js +296 -6
- 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
|
|
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,167 @@ 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
|
+
try {
|
|
787
|
+
renameSync(temporary, target);
|
|
788
|
+
} catch (error) {
|
|
789
|
+
// POSIX renames straight over an existing file; Windows refuses with
|
|
790
|
+
// EPERM/EEXIST. Two windows uploading the same bytes race here: the
|
|
791
|
+
// other one already wrote this exact content-addressed file, so a
|
|
792
|
+
// refusal is the outcome we wanted, not a failure.
|
|
793
|
+
if (existsSync(target)) {
|
|
794
|
+
rmSync(temporary, { force: true });
|
|
795
|
+
return fileName;
|
|
796
|
+
}
|
|
797
|
+
rmSync(temporary, { force: true });
|
|
798
|
+
throw error;
|
|
799
|
+
}
|
|
800
|
+
return fileName;
|
|
801
|
+
} catch (error) {
|
|
802
|
+
// A read-only profile or a full disk must not take the route down: the
|
|
803
|
+
// caller reports "not stored" and the page keeps whatever it had.
|
|
804
|
+
warn(`sound write failed: ${error?.message ?? error}`);
|
|
805
|
+
return undefined;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Delete one stored sound, ignoring a name that is not ours and a file that is
|
|
811
|
+
* already gone.
|
|
812
|
+
*
|
|
813
|
+
* @param fileName - the name to remove.
|
|
814
|
+
*/
|
|
815
|
+
export function deleteSoundFile(fileName) {
|
|
816
|
+
const path = soundFilePath(fileName);
|
|
817
|
+
if (path === undefined) return;
|
|
818
|
+
try {
|
|
819
|
+
rmSync(path, { force: true });
|
|
820
|
+
} catch (error) {
|
|
821
|
+
warn(`sound delete failed: ${error?.message ?? error}`);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* The MIME type for a stored name.
|
|
828
|
+
*
|
|
829
|
+
* @param fileName - a name already accepted by `soundFilePath`.
|
|
830
|
+
* @returns the content type to serve it with.
|
|
831
|
+
*/
|
|
832
|
+
export function soundMimeType(fileName) {
|
|
833
|
+
const extension = fileName.slice(fileName.lastIndexOf(".") + 1);
|
|
834
|
+
return SOUND_FORMATS.find((format) => format.extension === extension)?.mime ?? "application/octet-stream";
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Read a request body no larger than `limit`.
|
|
839
|
+
*
|
|
840
|
+
* The stream is abandoned the moment the cap is crossed rather than read to
|
|
841
|
+
* the end: an oversized upload is a mistake or an attack, and neither deserves
|
|
842
|
+
* the bandwidth.
|
|
843
|
+
*
|
|
844
|
+
* @param req - the incoming request.
|
|
845
|
+
* @param limit - the largest body accepted.
|
|
846
|
+
* @returns the body, or undefined when it is too large.
|
|
847
|
+
*/
|
|
848
|
+
async function readRequestBytes(req, limit) {
|
|
849
|
+
const chunks = [];
|
|
850
|
+
let total = 0;
|
|
851
|
+
for await (const chunk of req) {
|
|
852
|
+
total += chunk.length;
|
|
853
|
+
if (total > limit) return undefined;
|
|
854
|
+
chunks.push(chunk);
|
|
855
|
+
}
|
|
856
|
+
return Buffer.concat(chunks);
|
|
857
|
+
}
|
|
858
|
+
|
|
693
859
|
/**
|
|
694
860
|
* The profile directory that owns this installation, when there is one.
|
|
695
861
|
*
|
|
@@ -886,6 +1052,14 @@ export const Config = z.object({
|
|
|
886
1052
|
enabled: z.boolean().default(true).volatile(),
|
|
887
1053
|
/** Playback loudness in percent. */
|
|
888
1054
|
volume: z.number().step(1).min(0).max(100).default(60).volatile(),
|
|
1055
|
+
/**
|
|
1056
|
+
* Which loudness scale the stored `volume` speaks.
|
|
1057
|
+
*
|
|
1058
|
+
* 1.9.1 made every preset ~10 dB louder at the same number, so the page
|
|
1059
|
+
* re-scales a stored value once and sets this to 1. A config without it has
|
|
1060
|
+
* never been re-scaled, including one written by an older release.
|
|
1061
|
+
*/
|
|
1062
|
+
volumeRev: z.number().default(0).volatile(),
|
|
889
1063
|
/** Preset played when a Session gains a pending approval or plan review. */
|
|
890
1064
|
decisionSound: z.union(SOUND_IDS).default("bell").volatile(),
|
|
891
1065
|
/** Preset played when a Session gains a pending question. */
|
|
@@ -898,10 +1072,42 @@ export const Config = z.object({
|
|
|
898
1072
|
updateAutoCheck: z.boolean().default(true).volatile(),
|
|
899
1073
|
/** Play only while the page is not visible (background tab or window). */
|
|
900
1074
|
onlyWhenHidden: z.boolean().default(false).volatile(),
|
|
901
|
-
/**
|
|
1075
|
+
/**
|
|
1076
|
+
* The user's own sounds: up to three, each a stored file plus the name it
|
|
1077
|
+
* is shown under. A trigger picks one by slot id (`custom1`…`custom3`).
|
|
1078
|
+
*
|
|
1079
|
+
* Slots are FIXED, not compacted: deleting the second sound leaves slot 2
|
|
1080
|
+
* empty rather than shifting slot 3 into it, so a trigger pointing at
|
|
1081
|
+
* `custom3` keeps meaning the same recording.
|
|
1082
|
+
*/
|
|
1083
|
+
custom1File: z.string().default("").volatile(),
|
|
1084
|
+
custom1Name: z.string().default("").volatile(),
|
|
1085
|
+
custom2File: z.string().default("").volatile(),
|
|
1086
|
+
custom2Name: z.string().default("").volatile(),
|
|
1087
|
+
custom3File: z.string().default("").volatile(),
|
|
1088
|
+
custom3Name: z.string().default("").volatile(),
|
|
1089
|
+
/**
|
|
1090
|
+
* Legacy inline audio (up to 1.8.6), base64; empty when none.
|
|
1091
|
+
*
|
|
1092
|
+
* Kept readable so an install that updated itself from an older release
|
|
1093
|
+
* keeps its sound: the browser migrates it into slot 1 on first run and
|
|
1094
|
+
* only then clears it. Nothing writes this field any more.
|
|
1095
|
+
*/
|
|
902
1096
|
customSound: z.string().default("").volatile(),
|
|
903
|
-
/**
|
|
1097
|
+
/** Legacy display name that belonged to `customSound`. */
|
|
904
1098
|
customSoundName: z.string().default("").volatile(),
|
|
1099
|
+
/** Legacy per-trigger reference from 1.9.0, migrated into the list then cleared. */
|
|
1100
|
+
customDecisionFile: z.string().default("").volatile(),
|
|
1101
|
+
/** Legacy display name that belonged to `customDecisionFile`. */
|
|
1102
|
+
customDecisionName: z.string().default("").volatile(),
|
|
1103
|
+
/** Legacy per-trigger reference from 1.9.0. */
|
|
1104
|
+
customQuestionFile: z.string().default("").volatile(),
|
|
1105
|
+
/** Legacy display name that belonged to `customQuestionFile`. */
|
|
1106
|
+
customQuestionName: z.string().default("").volatile(),
|
|
1107
|
+
/** Legacy per-trigger reference from 1.9.0. */
|
|
1108
|
+
customCompletionFile: z.string().default("").volatile(),
|
|
1109
|
+
/** Legacy display name that belonged to `customCompletionFile`. */
|
|
1110
|
+
customCompletionName: z.string().default("").volatile(),
|
|
905
1111
|
/** Show an OS notification (when the page is hidden) in addition to the sound. */
|
|
906
1112
|
systemNotify: z.boolean().default(false).volatile(),
|
|
907
1113
|
/** Prefix the window title with a word (待处理 / 已完成) while the page is hidden. */
|
|
@@ -1077,4 +1283,88 @@ export function apply(ctx) {
|
|
|
1077
1283
|
},
|
|
1078
1284
|
}), "notify-sounds: goal phase route");
|
|
1079
1285
|
});
|
|
1286
|
+
|
|
1287
|
+
// Uploaded sounds (1.9.0): the bytes live as files beside the profile, not
|
|
1288
|
+
// inside the configuration, and the page asks for them by content hash.
|
|
1289
|
+
//
|
|
1290
|
+
// This is deliberately STATELESS: the Host does not read its own config
|
|
1291
|
+
// here. Volatile config values are live cells rather than plain strings,
|
|
1292
|
+
// so a Host-side read would have to unwrap them — and the browser already
|
|
1293
|
+
// holds the authoritative snapshot. The page therefore names the file it
|
|
1294
|
+
// wants (`?file=<sha>.<ext>`), and the Host only ever touches the one
|
|
1295
|
+
// directory it owns, after checking the name against a hash pattern.
|
|
1296
|
+
ctx.inject(["webServer", "connection"], (child) => {
|
|
1297
|
+
child.effect(() => child.webServer.register({
|
|
1298
|
+
kind: "exact",
|
|
1299
|
+
path: SOUND_PATH,
|
|
1300
|
+
handler: async (req, res) => {
|
|
1301
|
+
const rejection = child.connection.requestRejection(req);
|
|
1302
|
+
if (rejection !== undefined) {
|
|
1303
|
+
res.statusCode = rejection;
|
|
1304
|
+
res.end();
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1308
|
+
try {
|
|
1309
|
+
if (req.method === "GET" || req.method === "HEAD") {
|
|
1310
|
+
const name = url.searchParams.get("file") ?? "";
|
|
1311
|
+
const file = soundFilePath(name);
|
|
1312
|
+
// Read, do not stat: one call closes the window between
|
|
1313
|
+
// "the file exists" and "here are its bytes".
|
|
1314
|
+
let bytes;
|
|
1315
|
+
try {
|
|
1316
|
+
bytes = file === undefined ? undefined : readFileSync(file);
|
|
1317
|
+
} catch {
|
|
1318
|
+
bytes = undefined;
|
|
1319
|
+
}
|
|
1320
|
+
if (bytes === undefined) {
|
|
1321
|
+
res.statusCode = 404;
|
|
1322
|
+
res.end();
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
res.statusCode = 200;
|
|
1326
|
+
// Content-addressed: the name IS the digest, so the bytes
|
|
1327
|
+
// behind it can never change.
|
|
1328
|
+
res.setHeader("cache-control", "public, max-age=31536000, immutable");
|
|
1329
|
+
res.setHeader("content-type", soundMimeType(name));
|
|
1330
|
+
res.setHeader("content-length", String(bytes.length));
|
|
1331
|
+
res.end(req.method === "HEAD" ? undefined : bytes);
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
if (req.method === "POST") {
|
|
1335
|
+
const bytes = await readRequestBytes(req, SOUND_MAX_BYTES);
|
|
1336
|
+
if (bytes === undefined) {
|
|
1337
|
+
res.statusCode = 413;
|
|
1338
|
+
res.end();
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
const name = writeSoundFile(bytes);
|
|
1342
|
+
if (name === undefined) {
|
|
1343
|
+
res.statusCode = 415;
|
|
1344
|
+
res.end();
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
res.statusCode = 200;
|
|
1348
|
+
res.setHeader("content-type", "application/json");
|
|
1349
|
+
res.end(JSON.stringify({ file: name }));
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
if (req.method === "DELETE") {
|
|
1353
|
+
const name = url.searchParams.get("file") ?? "";
|
|
1354
|
+
deleteSoundFile(name);
|
|
1355
|
+
res.statusCode = 204;
|
|
1356
|
+
res.end();
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
res.statusCode = 405;
|
|
1360
|
+
res.setHeader("allow", "GET, POST, DELETE");
|
|
1361
|
+
res.end();
|
|
1362
|
+
} catch (error) {
|
|
1363
|
+
warn(`sound store failed: ${error?.message ?? error}`);
|
|
1364
|
+
res.statusCode = 500;
|
|
1365
|
+
res.end();
|
|
1366
|
+
}
|
|
1367
|
+
},
|
|
1368
|
+
}), "notify-sounds: sound store route");
|
|
1369
|
+
});
|
|
1080
1370
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-notify-sounds",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.2",
|
|
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",
|