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/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
  }
@@ -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
- * The only files an update may replace, as package-relative paths.
822
+ * Largest expansion accepted after gunzip.
738
823
  *
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.
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 UPDATE_FILES = ["package.json", "cordis.patch.yml", "README.md", "README.zh.md", "LICENSE", "lib/client.js", "lib/index.js"];
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 replace.
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, only the whitelisted names,
772
- * and never a path that climbs, escapes, or arrives by another separator.
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
- if (normalized.startsWith("/") || normalized.split("/").includes("..")) throw new Error(`refusing tar path: ${path}`);
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 === "" || !UPDATE_FILES.includes(relative)) throw new Error(`refusing tar path: ${path}`);
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 few files an update may touch.
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
- * - a header claiming more than the archive holds is an error, not a read.
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
- files.set(archivePathToRelative(prefix === "" ? name : `${prefix}/${name}`), content);
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 UPDATE_FILES) {
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
- if (existsSync(target)) {
897
- const aside = join(backup, relative);
898
- mkdirSync(dirname(aside), { recursive: true });
899
- writeFileSync(aside, readFileSync(target));
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. 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.
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 UPDATE_FILES) rmSync(tempName(relative), { force: true });
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 whitelisted regular
934
- * files; then back up what is there and swap the files, restoring on failure.
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
- const files = unpackTar(gunzipSync(bytes));
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.6.9",
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
+ }