dsh-plugin-notify-sounds 1.6.8 → 1.7.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 +120 -110
- package/README.zh.md +188 -0
- package/lib/client.js +344 -63
- package/lib/index.js +339 -37
- package/package.json +2 -3
- package/README.en.md +0 -180
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
|
}
|
|
@@ -734,14 +779,41 @@ export const UPDATE_APPLY_PATH = "/notify-sounds/update/apply";
|
|
|
734
779
|
const UPDATE_MAX_TARBALL_BYTES = 8 * 1024 * 1024;
|
|
735
780
|
|
|
736
781
|
/**
|
|
737
|
-
*
|
|
782
|
+
* Largest expansion accepted after gunzip.
|
|
783
|
+
*
|
|
784
|
+
* The download cap alone does not bound memory: a few-hundred-KB gzip stream
|
|
785
|
+
* can expand to gigabytes, and that allocation happens inside this process.
|
|
786
|
+
* `gunzipSync` is given this as `maxOutputLength` so the limit is enforced
|
|
787
|
+
* while inflating rather than after.
|
|
788
|
+
*/
|
|
789
|
+
const UPDATE_MAX_UNPACKED_BYTES = UPDATE_MAX_TARBALL_BYTES * 8;
|
|
790
|
+
|
|
791
|
+
/** Most files one release of this package may carry. It is a handful of text files. */
|
|
792
|
+
const UPDATE_MAX_FILES = 256;
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Path segments an update may never contain, however an archive spells them.
|
|
796
|
+
*
|
|
797
|
+
* `.update-backup` is this updater's own working directory: an archive that
|
|
798
|
+
* carried a file with that name could leave the previous version's bytes
|
|
799
|
+
* exactly where a rollback looks for them. `node_modules` and `.git` are never
|
|
800
|
+
* published in a tarball, so an archive containing them describes a different
|
|
801
|
+
* tree than the one this installation came from.
|
|
802
|
+
*/
|
|
803
|
+
const UPDATE_RESERVED_SEGMENTS = new Set(["node_modules", ".git", ".update-backup"]);
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Records which files this updater installed, so the next update can prune.
|
|
738
807
|
*
|
|
739
|
-
*
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
808
|
+
* It lives in the installed package directory and is written by the updater at
|
|
809
|
+
* runtime — it is NEVER part of the published tarball, which is why adding it
|
|
810
|
+
* cannot break an older installation's update (those check the archive's file
|
|
811
|
+
* names, not what the directory happens to contain).
|
|
743
812
|
*/
|
|
744
|
-
const
|
|
813
|
+
const UPDATE_MANIFEST = ".update-manifest.json";
|
|
814
|
+
|
|
815
|
+
/** Package-root names a package manager reads as configuration, and the updater's own bookkeeping; never delivered by an update. */
|
|
816
|
+
const UPDATE_RESERVED_ROOT_FILES = new Set([".npmrc", ".pnpmfile.cjs", UPDATE_MANIFEST]);
|
|
745
817
|
|
|
746
818
|
/** Where this package is installed: the only tree an update may write to. */
|
|
747
819
|
function packageRoot() {
|
|
@@ -765,24 +837,43 @@ function readTarOctal(header, offset, length) {
|
|
|
765
837
|
}
|
|
766
838
|
|
|
767
839
|
/**
|
|
768
|
-
* Map an archive path to the package-relative file it may
|
|
840
|
+
* Map an archive path to the package-relative file it may write.
|
|
769
841
|
*
|
|
770
842
|
* Everything about a tar path is hostile until shown otherwise: only the
|
|
771
|
-
* `package/` prefix the registry publishes under,
|
|
772
|
-
*
|
|
843
|
+
* `package/` prefix the registry publishes under, never a path that climbs,
|
|
844
|
+
* escapes, or arrives by another separator, never a reserved name, and never
|
|
845
|
+
* one that an NTFS alternate data stream could hide behind.
|
|
846
|
+
*
|
|
847
|
+
* What is deliberately NOT checked is the file's NAME. An exact list of seven
|
|
848
|
+
* names was the previous design and it was a trap: the updater that runs during
|
|
849
|
+
* an update is the one already installed, so its list decides what the NEXT
|
|
850
|
+
* release may contain. A release that added a file became uninstallable by
|
|
851
|
+
* every older installation — permanently, and with no way to fix it after the
|
|
852
|
+
* fact, because the old code is already on those machines. The rules below are
|
|
853
|
+
* the ones that never need to change (path safety, not a file inventory), so
|
|
854
|
+
* they can remain the whole gate while the file set moves forward.
|
|
773
855
|
*/
|
|
774
856
|
export function archivePathToRelative(path) {
|
|
775
857
|
const normalized = path.replaceAll("\\", "/");
|
|
776
|
-
|
|
858
|
+
const segments = normalized.split("/");
|
|
859
|
+
if (normalized.startsWith("/") || segments.includes("..")) throw new Error(`refusing tar path: ${path}`);
|
|
777
860
|
const prefix = "package/";
|
|
778
861
|
if (!normalized.startsWith(prefix)) throw new Error(`refusing tar path: ${path}`);
|
|
779
862
|
const relative = normalized.slice(prefix.length);
|
|
780
|
-
if (relative === "" ||
|
|
863
|
+
if (relative === "" || relative.endsWith("/")) throw new Error(`refusing tar path: ${path}`);
|
|
864
|
+
for (const segment of relative.split("/")) {
|
|
865
|
+
// A `.` segment would let one file be named twice in one archive, and it
|
|
866
|
+
// slips past the case check below, which compares names rather than
|
|
867
|
+
// resolved paths.
|
|
868
|
+
if (segment === "" || segment === "." || segment.includes(":")) throw new Error(`refusing tar path: ${path}`);
|
|
869
|
+
if (UPDATE_RESERVED_SEGMENTS.has(segment) || segment.endsWith(".update-tmp")) throw new Error(`refusing tar path: ${path}`);
|
|
870
|
+
}
|
|
871
|
+
if (UPDATE_RESERVED_ROOT_FILES.has(relative)) throw new Error(`refusing tar path: ${path}`);
|
|
781
872
|
return relative;
|
|
782
873
|
}
|
|
783
874
|
|
|
784
875
|
/**
|
|
785
|
-
* Read a tar archive into the
|
|
876
|
+
* Read a tar archive into the files an update may write.
|
|
786
877
|
*
|
|
787
878
|
* The archive arrived from the network and is treated accordingly:
|
|
788
879
|
*
|
|
@@ -790,13 +881,19 @@ export function archivePathToRelative(path) {
|
|
|
790
881
|
* anywhere, so any other entry type is refused outright;
|
|
791
882
|
* - pax/gnu extension headers are skipped, not interpreted;
|
|
792
883
|
* - every name must survive `archivePathToRelative`, so nothing outside
|
|
793
|
-
* this package is ever named;
|
|
794
|
-
* -
|
|
884
|
+
* this package is ever named and nothing reserved is ever written;
|
|
885
|
+
* - two entries may not differ only in case: on Windows and macOS the second
|
|
886
|
+
* would silently overwrite the first;
|
|
887
|
+
* - a header claiming more than the archive holds is an error, not a read;
|
|
888
|
+
* - the total size and the file count are capped, so an archive cannot turn
|
|
889
|
+
* a bounded download into unbounded disk usage.
|
|
795
890
|
*
|
|
796
891
|
* @returns a Map of package-relative path → contents.
|
|
797
892
|
*/
|
|
798
893
|
export function unpackTar(bytes) {
|
|
799
894
|
const files = new Map();
|
|
895
|
+
const seen = new Map();
|
|
896
|
+
let total = 0;
|
|
800
897
|
let offset = 0;
|
|
801
898
|
while (offset + 512 <= bytes.length) {
|
|
802
899
|
const header = bytes.subarray(offset, offset + 512);
|
|
@@ -812,12 +909,79 @@ export function unpackTar(bytes) {
|
|
|
812
909
|
offset = contentStart + Math.ceil(size / 512) * 512;
|
|
813
910
|
if (typeflag === "x" || typeflag === "g" || typeflag === "L") continue;
|
|
814
911
|
if (typeflag !== "0" && typeflag !== "") throw new Error(`refusing non-file tar entry (${typeflag})`);
|
|
815
|
-
|
|
912
|
+
const relative = archivePathToRelative(prefix === "" ? name : `${prefix}/${name}`);
|
|
913
|
+
const folded = relative.toLowerCase();
|
|
914
|
+
const collidesWith = seen.get(folded);
|
|
915
|
+
if (collidesWith !== undefined && collidesWith !== relative) {
|
|
916
|
+
throw new Error(`refusing two entries that differ only in case: ${collidesWith} / ${relative}`);
|
|
917
|
+
}
|
|
918
|
+
seen.set(folded, relative);
|
|
919
|
+
total += content.length;
|
|
920
|
+
if (total > UPDATE_MAX_UNPACKED_BYTES) throw new Error("the archive expands beyond its limit");
|
|
921
|
+
if (files.size >= UPDATE_MAX_FILES) throw new Error("the archive carries more files than this package can have");
|
|
922
|
+
files.set(relative, content);
|
|
816
923
|
}
|
|
817
924
|
if (files.size === 0) throw new Error("the archive carried no usable files");
|
|
818
925
|
return files;
|
|
819
926
|
}
|
|
820
927
|
|
|
928
|
+
/**
|
|
929
|
+
* Check that the archive really is this package, at the version the registry named.
|
|
930
|
+
*
|
|
931
|
+
* With the file inventory gone, this is what still ties the bytes to an
|
|
932
|
+
* identity. The registry answer was validated before anything was downloaded,
|
|
933
|
+
* and the digest binds the bytes to that answer's `dist` — but the archive's
|
|
934
|
+
* own manifest is the only thing that says WHICH package and version those
|
|
935
|
+
* bytes are, and the next check trusts the manifest this update leaves on
|
|
936
|
+
* disk. A manifest that is missing, unreadable, or about something else would
|
|
937
|
+
* otherwise be installed silently and make "an update landed" a lie the check
|
|
938
|
+
* cannot see through.
|
|
939
|
+
*
|
|
940
|
+
* @param files - the unpacked archive, package-relative path → contents.
|
|
941
|
+
* @param version - the version the registry announced.
|
|
942
|
+
* @returns the parsed manifest.
|
|
943
|
+
*/
|
|
944
|
+
export function verifyManifest(files, version) {
|
|
945
|
+
const manifest = files.get("package.json");
|
|
946
|
+
if (manifest === undefined) throw new Error("the archive carries no package.json");
|
|
947
|
+
let parsed;
|
|
948
|
+
try {
|
|
949
|
+
parsed = JSON.parse(new TextDecoder().decode(manifest));
|
|
950
|
+
} catch {
|
|
951
|
+
throw new Error("the archive's package.json is not readable JSON");
|
|
952
|
+
}
|
|
953
|
+
if (parsed?.name !== UPDATE_PACKAGE) throw new Error(`the archive is not ${UPDATE_PACKAGE}`);
|
|
954
|
+
if (parsed?.version !== version) {
|
|
955
|
+
throw new Error(`the archive says version ${String(parsed?.version)} where the registry said ${version}`);
|
|
956
|
+
}
|
|
957
|
+
// A file whose name differs from a path this manifest declares only by case
|
|
958
|
+
// would replace that file on Windows and macOS and be a dead file on Linux:
|
|
959
|
+
// one archive, two different installations. The set is derived from the
|
|
960
|
+
// archive's OWN manifest (`package.json` itself, `main`, and every `exports`
|
|
961
|
+
// target) — never from a list held here — so a release stays free to move
|
|
962
|
+
// or rename its own files, which is the whole point of this design.
|
|
963
|
+
const declared = new Map([["package.json", "package.json"]]);
|
|
964
|
+
const declare = (value) => {
|
|
965
|
+
if (typeof value !== "string" || value === "") return;
|
|
966
|
+
const relative = value.startsWith("./") ? value.slice(2) : value;
|
|
967
|
+
declared.set(relative.toLowerCase(), relative);
|
|
968
|
+
};
|
|
969
|
+
const declareAll = (value) => {
|
|
970
|
+
if (typeof value === "string") declare(value);
|
|
971
|
+
else if (Array.isArray(value)) value.forEach(declareAll);
|
|
972
|
+
else if (value !== null && typeof value === "object") Object.values(value).forEach(declareAll);
|
|
973
|
+
};
|
|
974
|
+
declare(parsed.main);
|
|
975
|
+
declareAll(parsed.exports);
|
|
976
|
+
for (const relative of files.keys()) {
|
|
977
|
+
const target = declared.get(relative.toLowerCase());
|
|
978
|
+
if (target !== undefined && target !== relative) {
|
|
979
|
+
throw new Error(`refusing a file that differs only in case from ${target}: ${relative}`);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
return parsed;
|
|
983
|
+
}
|
|
984
|
+
|
|
821
985
|
/**
|
|
822
986
|
* Check downloaded bytes against the digest the registry published.
|
|
823
987
|
*
|
|
@@ -873,6 +1037,48 @@ async function downloadTarball(url) {
|
|
|
873
1037
|
throw new Error(`download refused: more than ${UPDATE_MAX_REDIRECTS} redirects`);
|
|
874
1038
|
}
|
|
875
1039
|
|
|
1040
|
+
/**
|
|
1041
|
+
* The file list a previous update recorded, as a Set.
|
|
1042
|
+
*
|
|
1043
|
+
* A missing, unreadable, or malformed manifest reads as "nothing recorded",
|
|
1044
|
+
* which prunes nothing: the failure mode of a damaged manifest has to be
|
|
1045
|
+
* "leaves a file behind", never "deletes something".
|
|
1046
|
+
*
|
|
1047
|
+
* @param root - the package directory.
|
|
1048
|
+
* @returns the recorded package-relative paths.
|
|
1049
|
+
*/
|
|
1050
|
+
function readUpdateManifest(root) {
|
|
1051
|
+
try {
|
|
1052
|
+
const parsed = JSON.parse(readFileSync(join(root, UPDATE_MANIFEST), "utf8"));
|
|
1053
|
+
if (!Array.isArray(parsed?.files)) return new Set();
|
|
1054
|
+
return new Set(parsed.files.filter((name) => typeof name === "string"));
|
|
1055
|
+
} catch {
|
|
1056
|
+
return new Set();
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Which recorded files this release no longer carries.
|
|
1062
|
+
*
|
|
1063
|
+
* Only paths the PREVIOUS update wrote are candidates, so a file the user put
|
|
1064
|
+
* in the package directory themselves is never named here and never removed.
|
|
1065
|
+
* This is what makes an update add and remove files while the file-list rule
|
|
1066
|
+
* stays out of the way — the alternative, shipping deletions inside the
|
|
1067
|
+
* archive, is not something a tar can express.
|
|
1068
|
+
*
|
|
1069
|
+
* @param previous - paths recorded by the last update.
|
|
1070
|
+
* @param files - the incoming archive's package-relative paths.
|
|
1071
|
+
* @returns the paths to delete, sorted.
|
|
1072
|
+
*/
|
|
1073
|
+
export function filesToPrune(previous, files) {
|
|
1074
|
+
const gone = [];
|
|
1075
|
+
for (const relative of previous) {
|
|
1076
|
+
if (relative === UPDATE_MANIFEST) continue;
|
|
1077
|
+
if (!files.has(relative)) gone.push(relative);
|
|
1078
|
+
}
|
|
1079
|
+
return gone.sort();
|
|
1080
|
+
}
|
|
1081
|
+
|
|
876
1082
|
/**
|
|
877
1083
|
* Swap the package's files for the update's, keeping the previous ones.
|
|
878
1084
|
*
|
|
@@ -880,41 +1086,127 @@ async function downloadTarball(url) {
|
|
|
880
1086
|
* failure cannot leave a half-written file under a real name. Everything the
|
|
881
1087
|
* update is about to replace is copied aside first, and any failure puts it
|
|
882
1088
|
* all back: a failed update is not a half-updated plugin.
|
|
1089
|
+
*
|
|
1090
|
+
* The files are whatever the archive carried, not a list held here. An earlier
|
|
1091
|
+
* design iterated a fixed set of seven names, which meant a release could only
|
|
1092
|
+
* ever touch those seven; that list is gone on purpose (see
|
|
1093
|
+
* `archivePathToRelative`), and this function is the second half of the same
|
|
1094
|
+
* decision — unpacking a new file and then never writing it would look exactly
|
|
1095
|
+
* like a successful update that quietly did nothing.
|
|
883
1096
|
*/
|
|
884
1097
|
export function installUpdate(root, files) {
|
|
885
1098
|
const rootPrefix = resolve(root);
|
|
886
1099
|
const backup = join(root, ".update-backup");
|
|
887
1100
|
const tempName = (relative) => `${resolve(root, relative)}.update-tmp`;
|
|
888
1101
|
rmSync(backup, { recursive: true, force: true });
|
|
1102
|
+
// The manifest goes last, and that order is load-bearing. If this process
|
|
1103
|
+
// dies mid-swap — a power cut or a kill, not a thrown error, which rolls
|
|
1104
|
+
// back — the installation is left with the OLD version number over part of
|
|
1105
|
+
// the new code, so the next check still reports an update and the user can
|
|
1106
|
+
// retry. The opposite order would leave a NEW version number over the old
|
|
1107
|
+
// code: the check would call that "up to date" and there would be nothing
|
|
1108
|
+
// left to prompt anyone with.
|
|
1109
|
+
const relatives = [...files.keys()].sort((a, b) => (a === "package.json" ? 1 : 0) - (b === "package.json" ? 1 : 0));
|
|
1110
|
+
/** Directories this update creates, outermost first, so a rollback can take them back. */
|
|
1111
|
+
const created = [];
|
|
889
1112
|
const written = [];
|
|
1113
|
+
/** Files this update deletes, kept aside first so a rollback can bring them back. */
|
|
1114
|
+
const pruned = [];
|
|
1115
|
+
/** The previous update's record, read before anything is written over it. */
|
|
1116
|
+
const previous = readUpdateManifest(root);
|
|
1117
|
+
const manifestPath = join(root, UPDATE_MANIFEST);
|
|
1118
|
+
/** Copy the current bytes aside before they are replaced or removed. */
|
|
1119
|
+
const backUp = (relative, target) => {
|
|
1120
|
+
const aside = join(backup, relative);
|
|
1121
|
+
mkdirSync(dirname(aside), { recursive: true });
|
|
1122
|
+
writeFileSync(aside, readFileSync(target));
|
|
1123
|
+
};
|
|
890
1124
|
try {
|
|
891
|
-
for (const relative of
|
|
1125
|
+
for (const relative of relatives) {
|
|
892
1126
|
const source = files.get(relative);
|
|
893
1127
|
if (source === undefined) continue;
|
|
894
1128
|
const target = resolve(root, relative);
|
|
895
1129
|
if (target !== rootPrefix && !target.startsWith(rootPrefix)) throw new Error(`refusing to write outside the package: ${relative}`);
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1130
|
+
let parent = rootPrefix;
|
|
1131
|
+
for (const segment of relative.split("/").slice(0, -1)) {
|
|
1132
|
+
parent = join(parent, segment);
|
|
1133
|
+
if (!existsSync(parent)) created.push(parent);
|
|
900
1134
|
}
|
|
1135
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
1136
|
+
if (existsSync(target)) backUp(relative, target);
|
|
901
1137
|
const temp = tempName(relative);
|
|
902
1138
|
writeFileSync(temp, source);
|
|
903
1139
|
renameSync(temp, target);
|
|
904
1140
|
written.push(relative);
|
|
905
1141
|
}
|
|
1142
|
+
// A release can also DROP a file, and an archive cannot say so: it is a
|
|
1143
|
+
// list of what exists, not of what should be deleted. The previous
|
|
1144
|
+
// update's record is what makes the difference computable, and it is
|
|
1145
|
+
// deliberately limited to files an update wrote — anything else in this
|
|
1146
|
+
// directory (a user's own notes, a tool's leftovers) is never named in
|
|
1147
|
+
// the record and therefore never deleted.
|
|
1148
|
+
for (const relative of filesToPrune(previous, files)) {
|
|
1149
|
+
const target = resolve(root, relative);
|
|
1150
|
+
if (target !== rootPrefix && !target.startsWith(rootPrefix)) continue;
|
|
1151
|
+
// Files only. A path that is now a directory is left alone rather
|
|
1152
|
+
// than failing the whole update, and never removed recursively.
|
|
1153
|
+
try {
|
|
1154
|
+
if (!statSync(target).isFile()) continue;
|
|
1155
|
+
} catch {
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
backUp(relative, target);
|
|
1159
|
+
rmSync(target, { force: true });
|
|
1160
|
+
pruned.push(relative);
|
|
1161
|
+
}
|
|
1162
|
+
// The record is written LAST, through the same temporary-name swap as
|
|
1163
|
+
// everything else, and it describes the state this update leaves behind:
|
|
1164
|
+
// the incoming files plus itself, minus nothing.
|
|
1165
|
+
if (existsSync(manifestPath)) backUp(UPDATE_MANIFEST, manifestPath);
|
|
1166
|
+
const record = new TextEncoder().encode(JSON.stringify({ version: 1, files: [...files.keys(), UPDATE_MANIFEST].sort() }));
|
|
1167
|
+
writeFileSync(`${manifestPath}.update-tmp`, record);
|
|
1168
|
+
renameSync(`${manifestPath}.update-tmp`, manifestPath);
|
|
1169
|
+
written.push(UPDATE_MANIFEST);
|
|
906
1170
|
} catch (error) {
|
|
907
1171
|
// Put everything back — including files the update ADDED, which have
|
|
908
|
-
// no backup to restore from and must simply go away again
|
|
909
|
-
//
|
|
910
|
-
//
|
|
1172
|
+
// no backup to restore from and must simply go away again, the files it
|
|
1173
|
+
// DELETED, which come back from their copies, and the directories it
|
|
1174
|
+
// created. A failed update has to leave nothing behind: no half-replaced
|
|
1175
|
+
// file, no leftover temp, no file that was not there before, and no
|
|
1176
|
+
// empty directory either.
|
|
911
1177
|
for (const relative of written) {
|
|
912
1178
|
const aside = join(backup, relative);
|
|
913
1179
|
const target = resolve(root, relative);
|
|
914
1180
|
if (existsSync(aside)) writeFileSync(target, readFileSync(aside));
|
|
915
1181
|
else rmSync(target, { force: true });
|
|
916
1182
|
}
|
|
917
|
-
for (const relative of
|
|
1183
|
+
for (const relative of pruned) {
|
|
1184
|
+
const aside = join(backup, relative);
|
|
1185
|
+
if (existsSync(aside)) writeFileSync(resolve(root, relative), readFileSync(aside));
|
|
1186
|
+
}
|
|
1187
|
+
for (const relative of relatives) rmSync(tempName(relative), { force: true });
|
|
1188
|
+
// Guarded: a cleanup step must never replace the failure it is cleaning
|
|
1189
|
+
// up after (this path can hold a directory, and `rmSync` refuses one
|
|
1190
|
+
// without `recursive`).
|
|
1191
|
+
try {
|
|
1192
|
+
rmSync(`${manifestPath}.update-tmp`, { force: true });
|
|
1193
|
+
} catch {}
|
|
1194
|
+
// Deepest first, so a chain collapses one level at a time. `rmdirSync`
|
|
1195
|
+
// refuses a directory that still holds something, which is the point:
|
|
1196
|
+
// a failure here must never delete real content. (Not `rmSync` — that
|
|
1197
|
+
// one needs `recursive` even for an empty directory on Windows and
|
|
1198
|
+
// throws EISDIR, which would have left every created directory behind.)
|
|
1199
|
+
for (const directory of created.reverse()) {
|
|
1200
|
+
try {
|
|
1201
|
+
rmdirSync(directory);
|
|
1202
|
+
} catch {}
|
|
1203
|
+
}
|
|
1204
|
+
// The restore above is done, so the copies have no further use. If a
|
|
1205
|
+
// restore threw, this is never reached and the backup stays on disk —
|
|
1206
|
+
// which is the one case where keeping it is the safer outcome.
|
|
1207
|
+
try {
|
|
1208
|
+
rmSync(backup, { recursive: true, force: true });
|
|
1209
|
+
} catch {}
|
|
918
1210
|
throw error;
|
|
919
1211
|
}
|
|
920
1212
|
// The swap succeeded, so the backup has done its job. Leaving it would
|
|
@@ -930,8 +1222,10 @@ export function installUpdate(root, files) {
|
|
|
930
1222
|
* registry (the answer is validated as strictly as for a check); download the
|
|
931
1223
|
* tarball from the URL that answer named and nowhere else; verify its sha512
|
|
932
1224
|
* against the digest the registry published — a mismatch stops here, before a
|
|
933
|
-
* byte is written; unpack it in memory, accepting only
|
|
934
|
-
*
|
|
1225
|
+
* byte is written; unpack it in memory, accepting only regular files that stay
|
|
1226
|
+
* inside the package, and confirm its manifest is this package at the version
|
|
1227
|
+
* the registry announced; then back up what is there and swap the files,
|
|
1228
|
+
* restoring on failure.
|
|
935
1229
|
*
|
|
936
1230
|
* Nothing downloaded is ever executed here. The new code runs when DSH
|
|
937
1231
|
* restarts, which is the same trust model as the user updating by hand.
|
|
@@ -959,17 +1253,25 @@ async function runUpdate(options = {}) {
|
|
|
959
1253
|
const root = options.root ?? packageRoot();
|
|
960
1254
|
const current = readOwnVersion(root);
|
|
961
1255
|
let stage = "check";
|
|
1256
|
+
/** The version the registry announced, once there is one, for the failure report. */
|
|
1257
|
+
let announced;
|
|
962
1258
|
try {
|
|
963
1259
|
const answer = await fetchRegistryAnswer();
|
|
964
1260
|
if (answer === undefined) return { ok: false, stage, error: "no registry answered" };
|
|
965
1261
|
const ordering = current === undefined ? NaN : compareVersions(current, answer.version);
|
|
966
1262
|
if (Number.isFinite(ordering) && ordering >= 0) return { ok: true, upToDate: true, current };
|
|
1263
|
+
announced = answer.version;
|
|
967
1264
|
stage = "download";
|
|
968
1265
|
const bytes = await downloadTarball(answer.tarball);
|
|
969
1266
|
stage = "verify";
|
|
970
1267
|
verifyIntegrity(bytes, answer.integrity);
|
|
971
1268
|
stage = "unpack";
|
|
972
|
-
|
|
1269
|
+
// A gzip stream can expand far beyond the bytes it arrived in, so the
|
|
1270
|
+
// decompressor is told where to stop instead of being trusted to be
|
|
1271
|
+
// reasonable; the tarball cap above bounds the input, not the output.
|
|
1272
|
+
const files = unpackTar(gunzipSync(bytes, { maxOutputLength: UPDATE_MAX_UNPACKED_BYTES }));
|
|
1273
|
+
stage = "manifest";
|
|
1274
|
+
verifyManifest(files, answer.version);
|
|
973
1275
|
stage = "replace";
|
|
974
1276
|
installUpdate(root, files);
|
|
975
1277
|
// The answer that said "update available" is now a lie about this
|
|
@@ -986,7 +1288,7 @@ async function runUpdate(options = {}) {
|
|
|
986
1288
|
return { ok: true, from: current, to: answer.version, requiresRestart: true };
|
|
987
1289
|
} catch (error) {
|
|
988
1290
|
warn(`update failed at ${stage}: ${error?.message ?? error}`);
|
|
989
|
-
return { ok: false, stage, error: error?.message ?? String(error) };
|
|
1291
|
+
return { ok: false, stage, to: announced, error: error?.message ?? String(error) };
|
|
990
1292
|
}
|
|
991
1293
|
}
|
|
992
1294
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-notify-sounds",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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.
|
|
22
|
-
"assets"
|
|
21
|
+
"README.zh.md"
|
|
23
22
|
],
|
|
24
23
|
"license": "MIT",
|
|
25
24
|
"repository": {
|