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/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
- return Buffer.from(buffer.buffer, 0, length * 2).toString("utf16le");
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
- // Same core: a prerelease sorts before its own release (1.5.0-rc.1 < 1.5.0).
549
- if (left[3] !== right[3]) {
550
- if (left[3] === "") return 1;
551
- if (right[3] === "") return -1;
552
- return left[3] < right[3] ? -1 : 1;
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
- * The only files an update may replace, as package-relative paths.
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
- * An update is a file swap, not an install: it replaces exactly what the
740
- * package publishes and touches nothing else. The profile's own configuration
741
- * lives outside this directory and is not on this list — no update can reach
742
- * it, whatever an archive contains.
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 UPDATE_FILES = ["package.json", "cordis.patch.yml", "README.md", "README.zh.md", "LICENSE", "lib/client.js", "lib/index.js"];
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 replace.
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, only the whitelisted names,
772
- * and never a path that climbs, escapes, or arrives by another separator.
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
- if (normalized.startsWith("/") || normalized.split("/").includes("..")) throw new Error(`refusing tar path: ${path}`);
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 === "" || !UPDATE_FILES.includes(relative)) throw new Error(`refusing tar path: ${path}`);
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 few files an update may touch.
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
- * - a header claiming more than the archive holds is an error, not a read.
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
- files.set(archivePathToRelative(prefix === "" ? name : `${prefix}/${name}`), content);
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 UPDATE_FILES) {
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
- if (existsSync(target)) {
897
- const aside = join(backup, relative);
898
- mkdirSync(dirname(aside), { recursive: true });
899
- writeFileSync(aside, readFileSync(target));
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. A failed
909
- // update has to leave nothing behind: no half-replaced file, no
910
- // leftover temp, and no file that was not there before.
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 UPDATE_FILES) rmSync(tempName(relative), { force: true });
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 whitelisted regular
934
- * files; then back up what is there and swap the files, restoring on failure.
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
- const files = unpackTar(gunzipSync(bytes));
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.6.8",
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.en.md",
22
- "assets"
21
+ "README.zh.md"
23
22
  ],
24
23
  "license": "MIT",
25
24
  "repository": {