dsh-plugin-notify-sounds 1.6.9 → 1.8.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.
- package/README.md +22 -5
- package/README.zh.md +22 -5
- package/lib/client.js +473 -104
- package/lib/index.js +383 -71
- package/package.json +3 -4
package/lib/index.js
CHANGED
|
@@ -28,7 +28,7 @@ import z from "@deepseek-ai/schemastery";
|
|
|
28
28
|
import { spawn } from "node:child_process";
|
|
29
29
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
30
30
|
import { createRequire } from "node:module";
|
|
31
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
32
32
|
import { dirname, join, resolve } from "node:path";
|
|
33
33
|
import { fileURLToPath } from "node:url";
|
|
34
34
|
import { gunzipSync } from "node:zlib";
|
|
@@ -188,13 +188,22 @@ let nativeRaise;
|
|
|
188
188
|
* @param handle - the window to read.
|
|
189
189
|
* @returns the title text.
|
|
190
190
|
*/
|
|
191
|
-
function windowTitle(native, handle) {
|
|
191
|
+
export function windowTitle(native, handle) {
|
|
192
192
|
try {
|
|
193
193
|
const length = native.GetWindowTextLengthW(handle);
|
|
194
194
|
if (length <= 0) return "";
|
|
195
195
|
const buffer = new Uint16Array(length + 2);
|
|
196
|
-
native.GetWindowTextW(handle, buffer, buffer.length);
|
|
197
|
-
|
|
196
|
+
const copied = native.GetWindowTextW(handle, buffer, buffer.length);
|
|
197
|
+
// The two calls are not atomic: between them the page can rewrite its
|
|
198
|
+
// title (switching sessions is exactly when it does), and this call
|
|
199
|
+
// then copies fewer characters than the first one counted. Slicing by
|
|
200
|
+
// the OLD count would hand back a string with NULs on the end — which
|
|
201
|
+
// no real title ever contains, and which silently stops `endsWith`
|
|
202
|
+
// matching, i.e. one click that does nothing at all. Slice by what was
|
|
203
|
+
// actually copied, and treat a nonsense return as "read the old count"
|
|
204
|
+
// rather than trusting it.
|
|
205
|
+
const written = Number.isFinite(copied) && copied >= 0 ? Math.min(copied, length) : length;
|
|
206
|
+
return Buffer.from(buffer.buffer, 0, written * 2).toString("utf16le");
|
|
198
207
|
} catch {
|
|
199
208
|
return "";
|
|
200
209
|
}
|
|
@@ -545,11 +554,47 @@ export function compareVersions(a, b) {
|
|
|
545
554
|
for (let i = 0; i < 3; i++) {
|
|
546
555
|
if (left[i] !== right[i]) return left[i] - right[i];
|
|
547
556
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
557
|
+
return comparePrerelease(left[3], right[3]);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Compare two prerelease fields the way semver defines it, not as strings.
|
|
562
|
+
*
|
|
563
|
+
* Plain string order gets the common case wrong: `rc.10` sorts BEFORE `rc.9`
|
|
564
|
+
* ("1" < "9"), so a registry whose `latest` points at a later release candidate
|
|
565
|
+
* would look older than the one already installed and the update would never be
|
|
566
|
+
* offered. The rules are: identifiers are compared field by field; a numeric
|
|
567
|
+
* field compares numerically and ranks below an alphanumeric one; an
|
|
568
|
+
* alphanumeric field compares in ASCII order; and when every shared field is
|
|
569
|
+
* equal, the one with MORE fields wins (`1.0.0-rc.1` < `1.0.0-rc.1.1`).
|
|
570
|
+
*
|
|
571
|
+
* @param left - the prerelease field of the first version, "" for a release.
|
|
572
|
+
* @param right - the prerelease field of the second version.
|
|
573
|
+
* @returns negative, 0, or positive.
|
|
574
|
+
*/
|
|
575
|
+
function comparePrerelease(left, right) {
|
|
576
|
+
if (left === right) return 0;
|
|
577
|
+
// A release outranks any of its own prereleases (1.5.0 > 1.5.0-rc.1).
|
|
578
|
+
if (left === "") return 1;
|
|
579
|
+
if (right === "") return -1;
|
|
580
|
+
const a = left.split(".");
|
|
581
|
+
const b = right.split(".");
|
|
582
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
583
|
+
const x = a[i];
|
|
584
|
+
const y = b[i];
|
|
585
|
+
if (x === undefined) return -1;
|
|
586
|
+
if (y === undefined) return 1;
|
|
587
|
+
const numericX = /^\d+$/.test(x);
|
|
588
|
+
const numericY = /^\d+$/.test(y);
|
|
589
|
+
if (numericX && numericY) {
|
|
590
|
+
const dx = Number(x);
|
|
591
|
+
const dy = Number(y);
|
|
592
|
+
if (dx !== dy) return dx < dy ? -1 : 1;
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
// A numeric identifier always ranks below an alphanumeric one.
|
|
596
|
+
if (numericX !== numericY) return numericX ? -1 : 1;
|
|
597
|
+
if (x !== y) return x < y ? -1 : 1;
|
|
553
598
|
}
|
|
554
599
|
return 0;
|
|
555
600
|
}
|
|
@@ -638,6 +683,41 @@ async function readCappedBytes(response, cap) {
|
|
|
638
683
|
* @returns `{ ok, current, latest, updateAvailable, checkedAt }`, or
|
|
639
684
|
* `{ ok: false, current, error }` when no registry answered.
|
|
640
685
|
*/
|
|
686
|
+
/**
|
|
687
|
+
* How the running package relates to what the profile's package manager
|
|
688
|
+
* recorded for it.
|
|
689
|
+
*
|
|
690
|
+
* This plugin no longer installs its own releases (as of 1.8.0): a release
|
|
691
|
+
* reaches a profile through the package manager, which also owns the
|
|
692
|
+
* lockfile. A tree that arrived by any other route looks identical on disk
|
|
693
|
+
* while the lockfile still pins an older version — and the next
|
|
694
|
+
* `pnpm install` in that profile silently restores the old one. Reading the
|
|
695
|
+
* version the lockfile would restore lets the Settings row say so instead of
|
|
696
|
+
* leaving the user to discover it after the next plugin operation.
|
|
697
|
+
*
|
|
698
|
+
* Every part is optional: no profile directory, no lockfile or an unreadable
|
|
699
|
+
* one simply reports nothing, and the row keeps its normal wording.
|
|
700
|
+
*
|
|
701
|
+
* @param current - the version actually on disk.
|
|
702
|
+
* @returns the fields the check payload mirrors to the row.
|
|
703
|
+
*/
|
|
704
|
+
function installationProvenance(current) {
|
|
705
|
+
try {
|
|
706
|
+
const profileDir = process.env.DSH_PROFILE_DIR;
|
|
707
|
+
if (profileDir === undefined) return {};
|
|
708
|
+
const lock = readFileSync(join(profileDir, "pnpm-lock.yaml"), "utf8");
|
|
709
|
+
const recorded = new RegExp(`${UPDATE_PACKAGE}@([0-9][^:\\s"']*)`).exec(lock)?.[1];
|
|
710
|
+
return {
|
|
711
|
+
recorded,
|
|
712
|
+
unregistered: current !== undefined && recorded !== undefined && recorded !== current,
|
|
713
|
+
profile: process.env.DSH_PROFILE,
|
|
714
|
+
};
|
|
715
|
+
} catch {
|
|
716
|
+
return {};
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
export { installationProvenance };
|
|
720
|
+
|
|
641
721
|
export async function checkForUpdate(options = {}) {
|
|
642
722
|
const now = Date.now();
|
|
643
723
|
if (options.force !== true && updateCheck.result !== undefined && now - updateCheck.checkedAt < UPDATE_CHECK_TTL_MS) {
|
|
@@ -659,7 +739,7 @@ export async function checkForUpdate(options = {}) {
|
|
|
659
739
|
const current = readOwnVersion(options.root);
|
|
660
740
|
const answer = await fetchRegistryAnswer();
|
|
661
741
|
if (answer === undefined) {
|
|
662
|
-
const result = { ok: false, current, error: "no registry answered", applied: updateCheck.applied };
|
|
742
|
+
const result = { ok: false, current, error: "no registry answered", applied: updateCheck.applied, ...installationProvenance(current) };
|
|
663
743
|
updateCheck.checkedAt = now;
|
|
664
744
|
updateCheck.result = result;
|
|
665
745
|
return result;
|
|
@@ -672,6 +752,7 @@ export async function checkForUpdate(options = {}) {
|
|
|
672
752
|
updateAvailable: Number.isFinite(ordering) && ordering < 0,
|
|
673
753
|
checkedAt: now,
|
|
674
754
|
applied: updateCheck.applied,
|
|
755
|
+
...installationProvenance(current),
|
|
675
756
|
};
|
|
676
757
|
updateCheck.checkedAt = now;
|
|
677
758
|
updateCheck.result = result;
|
|
@@ -725,7 +806,11 @@ async function fetchRegistryAnswer() {
|
|
|
725
806
|
|
|
726
807
|
//#endregion
|
|
727
808
|
|
|
728
|
-
//#region update apply
|
|
809
|
+
//#region update apply — UNREACHABLE since 1.8.0: no route calls it any more.
|
|
810
|
+
// The Settings row sends the user to the plugin manager / package manager
|
|
811
|
+
// instead, so this plugin never writes its own files. The code below stays
|
|
812
|
+
// only until its tests retire with a later release; nothing in apply() can
|
|
813
|
+
// reach it.
|
|
729
814
|
|
|
730
815
|
/** Route the browser half asks for an update to be applied through. */
|
|
731
816
|
export const UPDATE_APPLY_PATH = "/notify-sounds/update/apply";
|
|
@@ -734,14 +819,41 @@ export const UPDATE_APPLY_PATH = "/notify-sounds/update/apply";
|
|
|
734
819
|
const UPDATE_MAX_TARBALL_BYTES = 8 * 1024 * 1024;
|
|
735
820
|
|
|
736
821
|
/**
|
|
737
|
-
*
|
|
822
|
+
* Largest expansion accepted after gunzip.
|
|
738
823
|
*
|
|
739
|
-
*
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
824
|
+
* The download cap alone does not bound memory: a few-hundred-KB gzip stream
|
|
825
|
+
* can expand to gigabytes, and that allocation happens inside this process.
|
|
826
|
+
* `gunzipSync` is given this as `maxOutputLength` so the limit is enforced
|
|
827
|
+
* while inflating rather than after.
|
|
743
828
|
*/
|
|
744
|
-
const
|
|
829
|
+
const UPDATE_MAX_UNPACKED_BYTES = UPDATE_MAX_TARBALL_BYTES * 8;
|
|
830
|
+
|
|
831
|
+
/** Most files one release of this package may carry. It is a handful of text files. */
|
|
832
|
+
const UPDATE_MAX_FILES = 256;
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Path segments an update may never contain, however an archive spells them.
|
|
836
|
+
*
|
|
837
|
+
* `.update-backup` is this updater's own working directory: an archive that
|
|
838
|
+
* carried a file with that name could leave the previous version's bytes
|
|
839
|
+
* exactly where a rollback looks for them. `node_modules` and `.git` are never
|
|
840
|
+
* published in a tarball, so an archive containing them describes a different
|
|
841
|
+
* tree than the one this installation came from.
|
|
842
|
+
*/
|
|
843
|
+
const UPDATE_RESERVED_SEGMENTS = new Set(["node_modules", ".git", ".update-backup"]);
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Records which files this updater installed, so the next update can prune.
|
|
847
|
+
*
|
|
848
|
+
* It lives in the installed package directory and is written by the updater at
|
|
849
|
+
* runtime — it is NEVER part of the published tarball, which is why adding it
|
|
850
|
+
* cannot break an older installation's update (those check the archive's file
|
|
851
|
+
* names, not what the directory happens to contain).
|
|
852
|
+
*/
|
|
853
|
+
const UPDATE_MANIFEST = ".update-manifest.json";
|
|
854
|
+
|
|
855
|
+
/** Package-root names a package manager reads as configuration, and the updater's own bookkeeping; never delivered by an update. */
|
|
856
|
+
const UPDATE_RESERVED_ROOT_FILES = new Set([".npmrc", ".pnpmfile.cjs", UPDATE_MANIFEST]);
|
|
745
857
|
|
|
746
858
|
/** Where this package is installed: the only tree an update may write to. */
|
|
747
859
|
function packageRoot() {
|
|
@@ -765,24 +877,43 @@ function readTarOctal(header, offset, length) {
|
|
|
765
877
|
}
|
|
766
878
|
|
|
767
879
|
/**
|
|
768
|
-
* Map an archive path to the package-relative file it may
|
|
880
|
+
* Map an archive path to the package-relative file it may write.
|
|
769
881
|
*
|
|
770
882
|
* Everything about a tar path is hostile until shown otherwise: only the
|
|
771
|
-
* `package/` prefix the registry publishes under,
|
|
772
|
-
*
|
|
883
|
+
* `package/` prefix the registry publishes under, never a path that climbs,
|
|
884
|
+
* escapes, or arrives by another separator, never a reserved name, and never
|
|
885
|
+
* one that an NTFS alternate data stream could hide behind.
|
|
886
|
+
*
|
|
887
|
+
* What is deliberately NOT checked is the file's NAME. An exact list of seven
|
|
888
|
+
* names was the previous design and it was a trap: the updater that runs during
|
|
889
|
+
* an update is the one already installed, so its list decides what the NEXT
|
|
890
|
+
* release may contain. A release that added a file became uninstallable by
|
|
891
|
+
* every older installation — permanently, and with no way to fix it after the
|
|
892
|
+
* fact, because the old code is already on those machines. The rules below are
|
|
893
|
+
* the ones that never need to change (path safety, not a file inventory), so
|
|
894
|
+
* they can remain the whole gate while the file set moves forward.
|
|
773
895
|
*/
|
|
774
896
|
export function archivePathToRelative(path) {
|
|
775
897
|
const normalized = path.replaceAll("\\", "/");
|
|
776
|
-
|
|
898
|
+
const segments = normalized.split("/");
|
|
899
|
+
if (normalized.startsWith("/") || segments.includes("..")) throw new Error(`refusing tar path: ${path}`);
|
|
777
900
|
const prefix = "package/";
|
|
778
901
|
if (!normalized.startsWith(prefix)) throw new Error(`refusing tar path: ${path}`);
|
|
779
902
|
const relative = normalized.slice(prefix.length);
|
|
780
|
-
if (relative === "" ||
|
|
903
|
+
if (relative === "" || relative.endsWith("/")) throw new Error(`refusing tar path: ${path}`);
|
|
904
|
+
for (const segment of relative.split("/")) {
|
|
905
|
+
// A `.` segment would let one file be named twice in one archive, and it
|
|
906
|
+
// slips past the case check below, which compares names rather than
|
|
907
|
+
// resolved paths.
|
|
908
|
+
if (segment === "" || segment === "." || segment.includes(":")) throw new Error(`refusing tar path: ${path}`);
|
|
909
|
+
if (UPDATE_RESERVED_SEGMENTS.has(segment) || segment.endsWith(".update-tmp")) throw new Error(`refusing tar path: ${path}`);
|
|
910
|
+
}
|
|
911
|
+
if (UPDATE_RESERVED_ROOT_FILES.has(relative)) throw new Error(`refusing tar path: ${path}`);
|
|
781
912
|
return relative;
|
|
782
913
|
}
|
|
783
914
|
|
|
784
915
|
/**
|
|
785
|
-
* Read a tar archive into the
|
|
916
|
+
* Read a tar archive into the files an update may write.
|
|
786
917
|
*
|
|
787
918
|
* The archive arrived from the network and is treated accordingly:
|
|
788
919
|
*
|
|
@@ -790,13 +921,19 @@ export function archivePathToRelative(path) {
|
|
|
790
921
|
* anywhere, so any other entry type is refused outright;
|
|
791
922
|
* - pax/gnu extension headers are skipped, not interpreted;
|
|
792
923
|
* - every name must survive `archivePathToRelative`, so nothing outside
|
|
793
|
-
* this package is ever named;
|
|
794
|
-
* -
|
|
924
|
+
* this package is ever named and nothing reserved is ever written;
|
|
925
|
+
* - two entries may not differ only in case: on Windows and macOS the second
|
|
926
|
+
* would silently overwrite the first;
|
|
927
|
+
* - a header claiming more than the archive holds is an error, not a read;
|
|
928
|
+
* - the total size and the file count are capped, so an archive cannot turn
|
|
929
|
+
* a bounded download into unbounded disk usage.
|
|
795
930
|
*
|
|
796
931
|
* @returns a Map of package-relative path → contents.
|
|
797
932
|
*/
|
|
798
933
|
export function unpackTar(bytes) {
|
|
799
934
|
const files = new Map();
|
|
935
|
+
const seen = new Map();
|
|
936
|
+
let total = 0;
|
|
800
937
|
let offset = 0;
|
|
801
938
|
while (offset + 512 <= bytes.length) {
|
|
802
939
|
const header = bytes.subarray(offset, offset + 512);
|
|
@@ -812,12 +949,79 @@ export function unpackTar(bytes) {
|
|
|
812
949
|
offset = contentStart + Math.ceil(size / 512) * 512;
|
|
813
950
|
if (typeflag === "x" || typeflag === "g" || typeflag === "L") continue;
|
|
814
951
|
if (typeflag !== "0" && typeflag !== "") throw new Error(`refusing non-file tar entry (${typeflag})`);
|
|
815
|
-
|
|
952
|
+
const relative = archivePathToRelative(prefix === "" ? name : `${prefix}/${name}`);
|
|
953
|
+
const folded = relative.toLowerCase();
|
|
954
|
+
const collidesWith = seen.get(folded);
|
|
955
|
+
if (collidesWith !== undefined && collidesWith !== relative) {
|
|
956
|
+
throw new Error(`refusing two entries that differ only in case: ${collidesWith} / ${relative}`);
|
|
957
|
+
}
|
|
958
|
+
seen.set(folded, relative);
|
|
959
|
+
total += content.length;
|
|
960
|
+
if (total > UPDATE_MAX_UNPACKED_BYTES) throw new Error("the archive expands beyond its limit");
|
|
961
|
+
if (files.size >= UPDATE_MAX_FILES) throw new Error("the archive carries more files than this package can have");
|
|
962
|
+
files.set(relative, content);
|
|
816
963
|
}
|
|
817
964
|
if (files.size === 0) throw new Error("the archive carried no usable files");
|
|
818
965
|
return files;
|
|
819
966
|
}
|
|
820
967
|
|
|
968
|
+
/**
|
|
969
|
+
* Check that the archive really is this package, at the version the registry named.
|
|
970
|
+
*
|
|
971
|
+
* With the file inventory gone, this is what still ties the bytes to an
|
|
972
|
+
* identity. The registry answer was validated before anything was downloaded,
|
|
973
|
+
* and the digest binds the bytes to that answer's `dist` — but the archive's
|
|
974
|
+
* own manifest is the only thing that says WHICH package and version those
|
|
975
|
+
* bytes are, and the next check trusts the manifest this update leaves on
|
|
976
|
+
* disk. A manifest that is missing, unreadable, or about something else would
|
|
977
|
+
* otherwise be installed silently and make "an update landed" a lie the check
|
|
978
|
+
* cannot see through.
|
|
979
|
+
*
|
|
980
|
+
* @param files - the unpacked archive, package-relative path → contents.
|
|
981
|
+
* @param version - the version the registry announced.
|
|
982
|
+
* @returns the parsed manifest.
|
|
983
|
+
*/
|
|
984
|
+
export function verifyManifest(files, version) {
|
|
985
|
+
const manifest = files.get("package.json");
|
|
986
|
+
if (manifest === undefined) throw new Error("the archive carries no package.json");
|
|
987
|
+
let parsed;
|
|
988
|
+
try {
|
|
989
|
+
parsed = JSON.parse(new TextDecoder().decode(manifest));
|
|
990
|
+
} catch {
|
|
991
|
+
throw new Error("the archive's package.json is not readable JSON");
|
|
992
|
+
}
|
|
993
|
+
if (parsed?.name !== UPDATE_PACKAGE) throw new Error(`the archive is not ${UPDATE_PACKAGE}`);
|
|
994
|
+
if (parsed?.version !== version) {
|
|
995
|
+
throw new Error(`the archive says version ${String(parsed?.version)} where the registry said ${version}`);
|
|
996
|
+
}
|
|
997
|
+
// A file whose name differs from a path this manifest declares only by case
|
|
998
|
+
// would replace that file on Windows and macOS and be a dead file on Linux:
|
|
999
|
+
// one archive, two different installations. The set is derived from the
|
|
1000
|
+
// archive's OWN manifest (`package.json` itself, `main`, and every `exports`
|
|
1001
|
+
// target) — never from a list held here — so a release stays free to move
|
|
1002
|
+
// or rename its own files, which is the whole point of this design.
|
|
1003
|
+
const declared = new Map([["package.json", "package.json"]]);
|
|
1004
|
+
const declare = (value) => {
|
|
1005
|
+
if (typeof value !== "string" || value === "") return;
|
|
1006
|
+
const relative = value.startsWith("./") ? value.slice(2) : value;
|
|
1007
|
+
declared.set(relative.toLowerCase(), relative);
|
|
1008
|
+
};
|
|
1009
|
+
const declareAll = (value) => {
|
|
1010
|
+
if (typeof value === "string") declare(value);
|
|
1011
|
+
else if (Array.isArray(value)) value.forEach(declareAll);
|
|
1012
|
+
else if (value !== null && typeof value === "object") Object.values(value).forEach(declareAll);
|
|
1013
|
+
};
|
|
1014
|
+
declare(parsed.main);
|
|
1015
|
+
declareAll(parsed.exports);
|
|
1016
|
+
for (const relative of files.keys()) {
|
|
1017
|
+
const target = declared.get(relative.toLowerCase());
|
|
1018
|
+
if (target !== undefined && target !== relative) {
|
|
1019
|
+
throw new Error(`refusing a file that differs only in case from ${target}: ${relative}`);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
return parsed;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
821
1025
|
/**
|
|
822
1026
|
* Check downloaded bytes against the digest the registry published.
|
|
823
1027
|
*
|
|
@@ -873,6 +1077,48 @@ async function downloadTarball(url) {
|
|
|
873
1077
|
throw new Error(`download refused: more than ${UPDATE_MAX_REDIRECTS} redirects`);
|
|
874
1078
|
}
|
|
875
1079
|
|
|
1080
|
+
/**
|
|
1081
|
+
* The file list a previous update recorded, as a Set.
|
|
1082
|
+
*
|
|
1083
|
+
* A missing, unreadable, or malformed manifest reads as "nothing recorded",
|
|
1084
|
+
* which prunes nothing: the failure mode of a damaged manifest has to be
|
|
1085
|
+
* "leaves a file behind", never "deletes something".
|
|
1086
|
+
*
|
|
1087
|
+
* @param root - the package directory.
|
|
1088
|
+
* @returns the recorded package-relative paths.
|
|
1089
|
+
*/
|
|
1090
|
+
function readUpdateManifest(root) {
|
|
1091
|
+
try {
|
|
1092
|
+
const parsed = JSON.parse(readFileSync(join(root, UPDATE_MANIFEST), "utf8"));
|
|
1093
|
+
if (!Array.isArray(parsed?.files)) return new Set();
|
|
1094
|
+
return new Set(parsed.files.filter((name) => typeof name === "string"));
|
|
1095
|
+
} catch {
|
|
1096
|
+
return new Set();
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Which recorded files this release no longer carries.
|
|
1102
|
+
*
|
|
1103
|
+
* Only paths the PREVIOUS update wrote are candidates, so a file the user put
|
|
1104
|
+
* in the package directory themselves is never named here and never removed.
|
|
1105
|
+
* This is what makes an update add and remove files while the file-list rule
|
|
1106
|
+
* stays out of the way — the alternative, shipping deletions inside the
|
|
1107
|
+
* archive, is not something a tar can express.
|
|
1108
|
+
*
|
|
1109
|
+
* @param previous - paths recorded by the last update.
|
|
1110
|
+
* @param files - the incoming archive's package-relative paths.
|
|
1111
|
+
* @returns the paths to delete, sorted.
|
|
1112
|
+
*/
|
|
1113
|
+
export function filesToPrune(previous, files) {
|
|
1114
|
+
const gone = [];
|
|
1115
|
+
for (const relative of previous) {
|
|
1116
|
+
if (relative === UPDATE_MANIFEST) continue;
|
|
1117
|
+
if (!files.has(relative)) gone.push(relative);
|
|
1118
|
+
}
|
|
1119
|
+
return gone.sort();
|
|
1120
|
+
}
|
|
1121
|
+
|
|
876
1122
|
/**
|
|
877
1123
|
* Swap the package's files for the update's, keeping the previous ones.
|
|
878
1124
|
*
|
|
@@ -880,41 +1126,127 @@ async function downloadTarball(url) {
|
|
|
880
1126
|
* failure cannot leave a half-written file under a real name. Everything the
|
|
881
1127
|
* update is about to replace is copied aside first, and any failure puts it
|
|
882
1128
|
* all back: a failed update is not a half-updated plugin.
|
|
1129
|
+
*
|
|
1130
|
+
* The files are whatever the archive carried, not a list held here. An earlier
|
|
1131
|
+
* design iterated a fixed set of seven names, which meant a release could only
|
|
1132
|
+
* ever touch those seven; that list is gone on purpose (see
|
|
1133
|
+
* `archivePathToRelative`), and this function is the second half of the same
|
|
1134
|
+
* decision — unpacking a new file and then never writing it would look exactly
|
|
1135
|
+
* like a successful update that quietly did nothing.
|
|
883
1136
|
*/
|
|
884
1137
|
export function installUpdate(root, files) {
|
|
885
1138
|
const rootPrefix = resolve(root);
|
|
886
1139
|
const backup = join(root, ".update-backup");
|
|
887
1140
|
const tempName = (relative) => `${resolve(root, relative)}.update-tmp`;
|
|
888
1141
|
rmSync(backup, { recursive: true, force: true });
|
|
1142
|
+
// The manifest goes last, and that order is load-bearing. If this process
|
|
1143
|
+
// dies mid-swap — a power cut or a kill, not a thrown error, which rolls
|
|
1144
|
+
// back — the installation is left with the OLD version number over part of
|
|
1145
|
+
// the new code, so the next check still reports an update and the user can
|
|
1146
|
+
// retry. The opposite order would leave a NEW version number over the old
|
|
1147
|
+
// code: the check would call that "up to date" and there would be nothing
|
|
1148
|
+
// left to prompt anyone with.
|
|
1149
|
+
const relatives = [...files.keys()].sort((a, b) => (a === "package.json" ? 1 : 0) - (b === "package.json" ? 1 : 0));
|
|
1150
|
+
/** Directories this update creates, outermost first, so a rollback can take them back. */
|
|
1151
|
+
const created = [];
|
|
889
1152
|
const written = [];
|
|
1153
|
+
/** Files this update deletes, kept aside first so a rollback can bring them back. */
|
|
1154
|
+
const pruned = [];
|
|
1155
|
+
/** The previous update's record, read before anything is written over it. */
|
|
1156
|
+
const previous = readUpdateManifest(root);
|
|
1157
|
+
const manifestPath = join(root, UPDATE_MANIFEST);
|
|
1158
|
+
/** Copy the current bytes aside before they are replaced or removed. */
|
|
1159
|
+
const backUp = (relative, target) => {
|
|
1160
|
+
const aside = join(backup, relative);
|
|
1161
|
+
mkdirSync(dirname(aside), { recursive: true });
|
|
1162
|
+
writeFileSync(aside, readFileSync(target));
|
|
1163
|
+
};
|
|
890
1164
|
try {
|
|
891
|
-
for (const relative of
|
|
1165
|
+
for (const relative of relatives) {
|
|
892
1166
|
const source = files.get(relative);
|
|
893
1167
|
if (source === undefined) continue;
|
|
894
1168
|
const target = resolve(root, relative);
|
|
895
1169
|
if (target !== rootPrefix && !target.startsWith(rootPrefix)) throw new Error(`refusing to write outside the package: ${relative}`);
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1170
|
+
let parent = rootPrefix;
|
|
1171
|
+
for (const segment of relative.split("/").slice(0, -1)) {
|
|
1172
|
+
parent = join(parent, segment);
|
|
1173
|
+
if (!existsSync(parent)) created.push(parent);
|
|
900
1174
|
}
|
|
1175
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
1176
|
+
if (existsSync(target)) backUp(relative, target);
|
|
901
1177
|
const temp = tempName(relative);
|
|
902
1178
|
writeFileSync(temp, source);
|
|
903
1179
|
renameSync(temp, target);
|
|
904
1180
|
written.push(relative);
|
|
905
1181
|
}
|
|
1182
|
+
// A release can also DROP a file, and an archive cannot say so: it is a
|
|
1183
|
+
// list of what exists, not of what should be deleted. The previous
|
|
1184
|
+
// update's record is what makes the difference computable, and it is
|
|
1185
|
+
// deliberately limited to files an update wrote — anything else in this
|
|
1186
|
+
// directory (a user's own notes, a tool's leftovers) is never named in
|
|
1187
|
+
// the record and therefore never deleted.
|
|
1188
|
+
for (const relative of filesToPrune(previous, files)) {
|
|
1189
|
+
const target = resolve(root, relative);
|
|
1190
|
+
if (target !== rootPrefix && !target.startsWith(rootPrefix)) continue;
|
|
1191
|
+
// Files only. A path that is now a directory is left alone rather
|
|
1192
|
+
// than failing the whole update, and never removed recursively.
|
|
1193
|
+
try {
|
|
1194
|
+
if (!statSync(target).isFile()) continue;
|
|
1195
|
+
} catch {
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
backUp(relative, target);
|
|
1199
|
+
rmSync(target, { force: true });
|
|
1200
|
+
pruned.push(relative);
|
|
1201
|
+
}
|
|
1202
|
+
// The record is written LAST, through the same temporary-name swap as
|
|
1203
|
+
// everything else, and it describes the state this update leaves behind:
|
|
1204
|
+
// the incoming files plus itself, minus nothing.
|
|
1205
|
+
if (existsSync(manifestPath)) backUp(UPDATE_MANIFEST, manifestPath);
|
|
1206
|
+
const record = new TextEncoder().encode(JSON.stringify({ version: 1, files: [...files.keys(), UPDATE_MANIFEST].sort() }));
|
|
1207
|
+
writeFileSync(`${manifestPath}.update-tmp`, record);
|
|
1208
|
+
renameSync(`${manifestPath}.update-tmp`, manifestPath);
|
|
1209
|
+
written.push(UPDATE_MANIFEST);
|
|
906
1210
|
} catch (error) {
|
|
907
1211
|
// Put everything back — including files the update ADDED, which have
|
|
908
|
-
// no backup to restore from and must simply go away again
|
|
909
|
-
//
|
|
910
|
-
//
|
|
1212
|
+
// no backup to restore from and must simply go away again, the files it
|
|
1213
|
+
// DELETED, which come back from their copies, and the directories it
|
|
1214
|
+
// created. A failed update has to leave nothing behind: no half-replaced
|
|
1215
|
+
// file, no leftover temp, no file that was not there before, and no
|
|
1216
|
+
// empty directory either.
|
|
911
1217
|
for (const relative of written) {
|
|
912
1218
|
const aside = join(backup, relative);
|
|
913
1219
|
const target = resolve(root, relative);
|
|
914
1220
|
if (existsSync(aside)) writeFileSync(target, readFileSync(aside));
|
|
915
1221
|
else rmSync(target, { force: true });
|
|
916
1222
|
}
|
|
917
|
-
for (const relative of
|
|
1223
|
+
for (const relative of pruned) {
|
|
1224
|
+
const aside = join(backup, relative);
|
|
1225
|
+
if (existsSync(aside)) writeFileSync(resolve(root, relative), readFileSync(aside));
|
|
1226
|
+
}
|
|
1227
|
+
for (const relative of relatives) rmSync(tempName(relative), { force: true });
|
|
1228
|
+
// Guarded: a cleanup step must never replace the failure it is cleaning
|
|
1229
|
+
// up after (this path can hold a directory, and `rmSync` refuses one
|
|
1230
|
+
// without `recursive`).
|
|
1231
|
+
try {
|
|
1232
|
+
rmSync(`${manifestPath}.update-tmp`, { force: true });
|
|
1233
|
+
} catch {}
|
|
1234
|
+
// Deepest first, so a chain collapses one level at a time. `rmdirSync`
|
|
1235
|
+
// refuses a directory that still holds something, which is the point:
|
|
1236
|
+
// a failure here must never delete real content. (Not `rmSync` — that
|
|
1237
|
+
// one needs `recursive` even for an empty directory on Windows and
|
|
1238
|
+
// throws EISDIR, which would have left every created directory behind.)
|
|
1239
|
+
for (const directory of created.reverse()) {
|
|
1240
|
+
try {
|
|
1241
|
+
rmdirSync(directory);
|
|
1242
|
+
} catch {}
|
|
1243
|
+
}
|
|
1244
|
+
// The restore above is done, so the copies have no further use. If a
|
|
1245
|
+
// restore threw, this is never reached and the backup stays on disk —
|
|
1246
|
+
// which is the one case where keeping it is the safer outcome.
|
|
1247
|
+
try {
|
|
1248
|
+
rmSync(backup, { recursive: true, force: true });
|
|
1249
|
+
} catch {}
|
|
918
1250
|
throw error;
|
|
919
1251
|
}
|
|
920
1252
|
// The swap succeeded, so the backup has done its job. Leaving it would
|
|
@@ -930,8 +1262,10 @@ export function installUpdate(root, files) {
|
|
|
930
1262
|
* registry (the answer is validated as strictly as for a check); download the
|
|
931
1263
|
* tarball from the URL that answer named and nowhere else; verify its sha512
|
|
932
1264
|
* against the digest the registry published — a mismatch stops here, before a
|
|
933
|
-
* byte is written; unpack it in memory, accepting only
|
|
934
|
-
*
|
|
1265
|
+
* byte is written; unpack it in memory, accepting only regular files that stay
|
|
1266
|
+
* inside the package, and confirm its manifest is this package at the version
|
|
1267
|
+
* the registry announced; then back up what is there and swap the files,
|
|
1268
|
+
* restoring on failure.
|
|
935
1269
|
*
|
|
936
1270
|
* Nothing downloaded is ever executed here. The new code runs when DSH
|
|
937
1271
|
* restarts, which is the same trust model as the user updating by hand.
|
|
@@ -959,17 +1293,25 @@ async function runUpdate(options = {}) {
|
|
|
959
1293
|
const root = options.root ?? packageRoot();
|
|
960
1294
|
const current = readOwnVersion(root);
|
|
961
1295
|
let stage = "check";
|
|
1296
|
+
/** The version the registry announced, once there is one, for the failure report. */
|
|
1297
|
+
let announced;
|
|
962
1298
|
try {
|
|
963
1299
|
const answer = await fetchRegistryAnswer();
|
|
964
1300
|
if (answer === undefined) return { ok: false, stage, error: "no registry answered" };
|
|
965
1301
|
const ordering = current === undefined ? NaN : compareVersions(current, answer.version);
|
|
966
1302
|
if (Number.isFinite(ordering) && ordering >= 0) return { ok: true, upToDate: true, current };
|
|
1303
|
+
announced = answer.version;
|
|
967
1304
|
stage = "download";
|
|
968
1305
|
const bytes = await downloadTarball(answer.tarball);
|
|
969
1306
|
stage = "verify";
|
|
970
1307
|
verifyIntegrity(bytes, answer.integrity);
|
|
971
1308
|
stage = "unpack";
|
|
972
|
-
|
|
1309
|
+
// A gzip stream can expand far beyond the bytes it arrived in, so the
|
|
1310
|
+
// decompressor is told where to stop instead of being trusted to be
|
|
1311
|
+
// reasonable; the tarball cap above bounds the input, not the output.
|
|
1312
|
+
const files = unpackTar(gunzipSync(bytes, { maxOutputLength: UPDATE_MAX_UNPACKED_BYTES }));
|
|
1313
|
+
stage = "manifest";
|
|
1314
|
+
verifyManifest(files, answer.version);
|
|
973
1315
|
stage = "replace";
|
|
974
1316
|
installUpdate(root, files);
|
|
975
1317
|
// The answer that said "update available" is now a lie about this
|
|
@@ -986,7 +1328,7 @@ async function runUpdate(options = {}) {
|
|
|
986
1328
|
return { ok: true, from: current, to: answer.version, requiresRestart: true };
|
|
987
1329
|
} catch (error) {
|
|
988
1330
|
warn(`update failed at ${stage}: ${error?.message ?? error}`);
|
|
989
|
-
return { ok: false, stage, error: error?.message ?? String(error) };
|
|
1331
|
+
return { ok: false, stage, to: announced, error: error?.message ?? String(error) };
|
|
990
1332
|
}
|
|
991
1333
|
}
|
|
992
1334
|
|
|
@@ -1032,6 +1374,8 @@ export const Config = z.object({
|
|
|
1032
1374
|
quietStart: z.string().pattern(TIME_OF_DAY).default("22:00").volatile(),
|
|
1033
1375
|
/** Quiet range end, "HH:MM" 24-hour. */
|
|
1034
1376
|
quietEnd: z.string().pattern(TIME_OF_DAY).default("08:00").volatile(),
|
|
1377
|
+
/** In goal mode, stay silent on intermediate rounds and chime once when the goal ends. */
|
|
1378
|
+
goalFinalOnly: z.boolean().default(false).volatile(),
|
|
1035
1379
|
});
|
|
1036
1380
|
|
|
1037
1381
|
/**
|
|
@@ -1134,37 +1478,5 @@ export function apply(ctx) {
|
|
|
1134
1478
|
},
|
|
1135
1479
|
}), "notify-sounds: update check route");
|
|
1136
1480
|
|
|
1137
|
-
// The update apply route: same fence, same fixed target. It accepts no
|
|
1138
|
-
// version and no URL from the caller — the update it performs is always
|
|
1139
|
-
// the one a registry described for this package, verified against that
|
|
1140
|
-
// registry's own digest before anything is written.
|
|
1141
|
-
child.effect(() => child.webServer.register({
|
|
1142
|
-
kind: "exact",
|
|
1143
|
-
path: UPDATE_APPLY_PATH,
|
|
1144
|
-
handler: async (req, res) => {
|
|
1145
|
-
const rejection = child.connection.requestRejection(req);
|
|
1146
|
-
if (rejection !== undefined) {
|
|
1147
|
-
res.statusCode = rejection;
|
|
1148
|
-
res.end();
|
|
1149
|
-
return;
|
|
1150
|
-
}
|
|
1151
|
-
if (req.method !== "POST") {
|
|
1152
|
-
res.statusCode = 405;
|
|
1153
|
-
res.setHeader("allow", "POST");
|
|
1154
|
-
res.end();
|
|
1155
|
-
return;
|
|
1156
|
-
}
|
|
1157
|
-
try {
|
|
1158
|
-
const result = await applyUpdate();
|
|
1159
|
-
res.statusCode = 200;
|
|
1160
|
-
res.setHeader("content-type", "application/json");
|
|
1161
|
-
res.end(JSON.stringify(result));
|
|
1162
|
-
} catch (error) {
|
|
1163
|
-
warn(`update apply failed: ${error?.message ?? error}`);
|
|
1164
|
-
res.statusCode = 500;
|
|
1165
|
-
res.end();
|
|
1166
|
-
}
|
|
1167
|
-
},
|
|
1168
|
-
}), "notify-sounds: update apply route");
|
|
1169
1481
|
});
|
|
1170
1482
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-notify-sounds",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Notification sounds for DeepSeek Harness — a soft chime when the agent needs your decision or finishes a task, with a Settings section styled like DSH's own · 需要决策或任务完成时播放提示音,点击系统通知可回到窗口,设置面板可调音量与音效",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -18,8 +18,7 @@
|
|
|
18
18
|
"lib/index.js",
|
|
19
19
|
"lib/client.js",
|
|
20
20
|
"cordis.patch.yml",
|
|
21
|
-
"README.zh.md"
|
|
22
|
-
"assets"
|
|
21
|
+
"README.zh.md"
|
|
23
22
|
],
|
|
24
23
|
"license": "MIT",
|
|
25
24
|
"repository": {
|
|
@@ -60,4 +59,4 @@
|
|
|
60
59
|
"platform": "web"
|
|
61
60
|
}
|
|
62
61
|
}
|
|
63
|
-
}
|
|
62
|
+
}
|