dsh-background-by-model 0.10.0 → 0.12.0-test.1

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/client.js CHANGED
@@ -103,12 +103,14 @@ window.__ModuleLoader__.load({
103
103
  id: "mid-autumn",
104
104
  slot: "h-midautumn",
105
105
  asset: "mid-autumn.webp",
106
+ source: "/bfs/new_dyn/42e22801827aff71267b03c95fcfd7834168597.png@1672w.webp",
106
107
  color: "#384A77",
107
108
  kind: "lunar"
108
109
  }, {
109
110
  id: "national-day",
110
111
  slot: "h-nationalday",
111
112
  asset: "national-day.webp",
113
+ source: "/bfs/new_dyn/30e97211189f7869dae83b909d2a3cc84168597.png@3456w.webp",
112
114
  color: "#FFF6EB",
113
115
  kind: "gregorian"
114
116
  }];
@@ -472,7 +474,9 @@ window.__ModuleLoader__.load({
472
474
  holidays: {
473
475
  enabled: false,
474
476
  items: defaultHolidayRules()
475
- }
477
+ },
478
+ agentAccess: false,
479
+ rev: 0
476
480
  };
477
481
  }
478
482
  /** Slot names reach the filesystem, so they are strictly whitelisted. */
@@ -738,7 +742,9 @@ window.__ModuleLoader__.load({
738
742
  rightbarOpacity: normalizeRightbarOpacity(r.rightbarOpacity),
739
743
  pageOpacity: clamp01(r.pageOpacity, 0),
740
744
  autoExtract: r.autoExtract !== false,
741
- holidays: normalizeHolidays(r.holidays)
745
+ holidays: normalizeHolidays(r.holidays),
746
+ agentAccess: r.agentAccess === true,
747
+ rev: typeof r.rev === "number" && isFinite(r.rev) && r.rev >= 0 ? Math.floor(r.rev) : 0
742
748
  };
743
749
  }
744
750
  freshThemeConfig();
@@ -1027,12 +1033,23 @@ window.__ModuleLoader__.load({
1027
1033
  }
1028
1034
  rotIndex = 0;
1029
1035
  }
1030
- function resetConfig() {
1031
- cfg = freshThemeConfig();
1032
- clearImages();
1033
- activeRuleId = null;
1034
- activeMatched = false;
1035
- rotIndex = 0;
1036
+ /**
1037
+ * Record the revision the host just wrote.
1038
+ *
1039
+ * Called after EVERY successful write, and it is load-bearing rather than
1040
+ * bookkeeping: the next write names this number as the one it started from, so
1041
+ * leaving it stale makes the very next save look like a lost race — the host
1042
+ * refuses it, hands back the config it already has (which does not contain the
1043
+ * edit), and the panel adopts that. That is the "click + twice" bug: the first
1044
+ * click is refused as stale and rolled back, the second only works once a stale
1045
+ * revision happens to match again.
1046
+ *
1047
+ * Mutated on the live object, never replaced: `cfg` is read through its binding
1048
+ * everywhere, and swapping the reference would strand the modules that hold it.
1049
+ */
1050
+ function setRev(rev) {
1051
+ if (!isFinite(rev) || rev < 0) return;
1052
+ cfg.rev = Math.floor(rev);
1036
1053
  }
1037
1054
  //#endregion
1038
1055
  //#region src/client/rpc.ts
@@ -1081,6 +1098,124 @@ window.__ModuleLoader__.load({
1081
1098
  }
1082
1099
  const SAVE_DEBOUNCE_MS = 250;
1083
1100
  let saveTimer;
1101
+ /**
1102
+ * Called when a write came back as a conflict, so the plugin can adopt the config
1103
+ * that won and repaint. Set once by `apply()` in ./index.
1104
+ *
1105
+ * A callback rather than an import because `./rpc` owns no state: the memory copy,
1106
+ * the repaint and the toast all live in the modules that own those, and this file
1107
+ * would have to import half the plugin to reach them.
1108
+ */
1109
+ let onConflict = null;
1110
+ function setConflictHandler(handler) {
1111
+ onConflict = handler;
1112
+ }
1113
+ /**
1114
+ * One config write, with the conflict case handled rather than logged.
1115
+ *
1116
+ * The generic `rpcCall` answers `undefined` for anything that is not a plain
1117
+ * success, which is right for reads but wrong here: a REFUSED write is not "no
1118
+ * answer", it is an answer that carries the config which won. So this one goes
1119
+ * through `rpcRaw` and unwraps the outcome itself.
1120
+ */
1121
+ async function writeConfigNow(config) {
1122
+ const res = await rpcRaw("writeConfig", {
1123
+ config,
1124
+ expectedRev: cfg.rev
1125
+ });
1126
+ if (res === void 0) return {
1127
+ ok: false,
1128
+ conflict: false,
1129
+ rev: cfg.rev
1130
+ };
1131
+ if (res.ok !== true) return {
1132
+ ok: false,
1133
+ conflict: false,
1134
+ rev: cfg.rev
1135
+ };
1136
+ const value = res.value;
1137
+ if (value?.ok === true) return {
1138
+ ok: true,
1139
+ conflict: false,
1140
+ rev: typeof value.rev === "number" ? value.rev : cfg.rev
1141
+ };
1142
+ if (value?.conflict === true) return {
1143
+ ok: false,
1144
+ conflict: true,
1145
+ config: value.config,
1146
+ rev: typeof value.rev === "number" ? value.rev : cfg.rev
1147
+ };
1148
+ return {
1149
+ ok: false,
1150
+ conflict: false,
1151
+ rev: cfg.rev
1152
+ };
1153
+ }
1154
+ /**
1155
+ * Persist, and adopt the winner when the write was refused as stale.
1156
+ *
1157
+ * Deliberately NOT retried: the edit that lost was computed from a config that is
1158
+ * no longer there, and replaying it on the winner would apply a change the user
1159
+ * made against a different state. Adopting is the honest answer — the user sees
1160
+ * the new configuration and can repeat the gesture if they still want it.
1161
+ *
1162
+ * The SUCCESS path is the one that has to be careful: a write that lands advances
1163
+ * the revision on disk, so this bundle's own copy must advance with it, or its next
1164
+ * save names a revision that is already gone, gets refused, and adopts the config
1165
+ * from BEFORE that save — which is the edit visibly undoing itself.
1166
+ */
1167
+ /**
1168
+ * ONE writer at a time, and this is load-bearing rather than tidy.
1169
+ *
1170
+ * Every save names the revision it started from, and a successful one ADVANCES that
1171
+ * revision. Two saves fired back to back (a fast double delete, a drag followed by a
1172
+ * click) therefore both name the SAME revision — the first is accepted, the second is
1173
+ * refused as stale, and the refusal hands back the config from before the second
1174
+ * edit. The panel adopts it, so the second edit disappears from the screen while its
1175
+ * side effects (a picture's bytes, already deleted) do not: that is the card reading
1176
+ * "这张图片还没有取到内容" next to a rule the user just deleted.
1177
+ *
1178
+ * Chaining the writes means the second one starts after the first has recorded its
1179
+ * revision, which is the only state it can be correct against. It is a single chain
1180
+ * for the whole config rather than one per key because the config IS one document —
1181
+ * two concurrent writes to it can never both be right.
1182
+ */
1183
+ let writeChain = Promise.resolve();
1184
+ /**
1185
+ * Work that must only happen once the config naming it has actually landed.
1186
+ *
1187
+ * Releasing a picture's bytes is the case: the bytes are what the config points AT,
1188
+ * so deleting them first and then losing the config write leaves the rule on screen
1189
+ * with nothing behind it. Queued here instead, a failed or superseded write simply
1190
+ * never runs the delete — the file stays, which is wrong only in that it is
1191
+ * unreferenced, and the cleanup card exists for exactly that.
1192
+ */
1193
+ let deferredDeletes = [];
1194
+ /**
1195
+ * Delete one slot's bytes, but only after the config that stopped referencing it is
1196
+ * on disk.
1197
+ */
1198
+ function deleteImageAfterSave(slot) {
1199
+ deferredDeletes.push(slot);
1200
+ }
1201
+ function persistAndResolve() {
1202
+ writeChain = writeChain.then(async () => {
1203
+ const outcome = await writeConfigNow(cfg);
1204
+ if (outcome.ok) {
1205
+ setRev(outcome.rev);
1206
+ const releasing = deferredDeletes;
1207
+ deferredDeletes = [];
1208
+ for (const slot of releasing) deleteImage(slot);
1209
+ return;
1210
+ }
1211
+ if (outcome.conflict && outcome.config !== void 0) {
1212
+ deferredDeletes = [];
1213
+ onConflict?.(outcome.config);
1214
+ }
1215
+ });
1216
+ return writeChain;
1217
+ }
1218
+ /** Write the current config with the revision this bundle last saw. */
1084
1219
  function saveConfig() {
1085
1220
  if (!canWriteConfig()) {
1086
1221
  holdWrite();
@@ -1089,7 +1224,7 @@ window.__ModuleLoader__.load({
1089
1224
  if (saveTimer !== void 0) window.clearTimeout(saveTimer);
1090
1225
  saveTimer = window.setTimeout(() => {
1091
1226
  saveTimer = void 0;
1092
- rpcCall("writeConfig", { config: cfg });
1227
+ persistAndResolve();
1093
1228
  }, SAVE_DEBOUNCE_MS);
1094
1229
  }
1095
1230
  function flushSave() {
@@ -1100,7 +1235,7 @@ window.__ModuleLoader__.load({
1100
1235
  holdWrite();
1101
1236
  return;
1102
1237
  }
1103
- rpcCall("writeConfig", { config: cfg });
1238
+ persistAndResolve();
1104
1239
  }
1105
1240
  /** Persist the current config immediately (no debounce). */
1106
1241
  function persistConfig() {
@@ -1108,7 +1243,19 @@ window.__ModuleLoader__.load({
1108
1243
  holdWrite();
1109
1244
  return;
1110
1245
  }
1111
- rpcCall("writeConfig", { config: cfg });
1246
+ persistAndResolve();
1247
+ }
1248
+ /**
1249
+ * Run the queued byte deletions without writing anything.
1250
+ *
1251
+ * The queue is normally drained by the next successful write, and every path that
1252
+ * queues one writes immediately afterwards — so this exists only for the page-hide
1253
+ * flush, where a delete could otherwise outlive the tab that asked for it.
1254
+ */
1255
+ function drainDeferredDeletes() {
1256
+ const releasing = deferredDeletes;
1257
+ deferredDeletes = [];
1258
+ for (const slot of releasing) deleteImage(slot);
1112
1259
  }
1113
1260
  /** Load the persisted config plus the list of stored image slots. */
1114
1261
  async function loadPersisted() {
@@ -1117,7 +1264,7 @@ window.__ModuleLoader__.load({
1117
1264
  const d = data;
1118
1265
  const slots = Array.isArray(d.slots) ? d.slots.filter((s) => typeof s === "string") : [];
1119
1266
  const schema = typeof d.schema === "number" && isFinite(d.schema) ? d.schema : null;
1120
- hostShape = typeof schema === "number" && schema >= 10 ? "current" : "legacy";
1267
+ hostShape = typeof schema === "number" && schema >= 11 ? "current" : "legacy";
1121
1268
  if (hostShape === "legacy") holdWrite();
1122
1269
  return {
1123
1270
  config: d.config,
@@ -1125,30 +1272,68 @@ window.__ModuleLoader__.load({
1125
1272
  schema
1126
1273
  };
1127
1274
  }
1275
+ /**
1276
+ * The revision of the config on disk, or null when the host could not be asked.
1277
+ *
1278
+ * The whole answer is one number: this runs on a timer, and pulling the config to
1279
+ * find out whether it changed would cost a full read every few seconds for a
1280
+ * question that is almost always "no".
1281
+ */
1282
+ async function readRev() {
1283
+ const data = await rpcCall("readRev", {});
1284
+ if (data === null || typeof data !== "object") return null;
1285
+ const rev = data.rev;
1286
+ return typeof rev === "number" && isFinite(rev) ? rev : null;
1287
+ }
1288
+ /**
1289
+ * Per-slot queue for the byte operations.
1290
+ *
1291
+ * Slots are RECYCLED — `nextSlot` hands out the first free `mN`, and deleting a
1292
+ * rule frees its numbers immediately — so a fast delete-then-add can name the same
1293
+ * slot twice within one round trip. Those two calls are independent requests on one
1294
+ * connection, which means their order at the host is whatever the transport decides
1295
+ * that moment: the delete can land AFTER the write, leaving the config naming a slot
1296
+ * whose bytes were just removed. The panel then paints a card that reads "这张图片还没有
1297
+ * 取到内容" — the rule is right there, the picture is not, and nothing reports an
1298
+ * error because nothing failed.
1299
+ *
1300
+ * So the operations on ONE slot run in the order they were asked for. Different
1301
+ * slots never wait for each other: a queue per slot, not one global one, because
1302
+ * adding seven pictures at once must not serialise into seven round trips.
1303
+ */
1304
+ const slotQueues = /* @__PURE__ */ new Map();
1305
+ function enqueueForSlot(slot, run) {
1306
+ const next = (slotQueues.get(slot) ?? Promise.resolve()).then(run, run);
1307
+ slotQueues.set(slot, next);
1308
+ next.finally(() => {
1309
+ if (slotQueues.get(slot) === next) slotQueues.delete(slot);
1310
+ });
1311
+ return next;
1312
+ }
1128
1313
  /** Read one rule image as a data URL (null when the slot is empty). */
1129
1314
  async function readImage(slot) {
1130
- const data = await rpcCall("readImage", { slot });
1315
+ const data = await enqueueForSlot(slot, () => rpcCall("readImage", { slot }));
1131
1316
  if (data === null || typeof data !== "object") return null;
1132
1317
  const url = data.dataUrl;
1133
1318
  return typeof url === "string" ? url : null;
1134
1319
  }
1135
1320
  /** Persist (or clear) one rule image; one-shot, no debounce. */
1136
1321
  async function writeImage(slot, dataUrl) {
1137
- return await rpcCall("writeImage", {
1322
+ return await enqueueForSlot(slot, () => rpcCall("writeImage", {
1138
1323
  slot,
1139
1324
  dataUrl
1140
- }) === true;
1325
+ })) === true;
1141
1326
  }
1142
1327
  /** Remove one rule image from disk. */
1143
1328
  async function deleteImage(slot) {
1144
- return await rpcCall("deleteImage", { slot }) === true;
1329
+ return await enqueueForSlot(slot, () => rpcCall("deleteImage", { slot })) === true;
1145
1330
  }
1146
1331
  /** Download an image from a network URL into one slot, replacing its bytes. */
1147
1332
  async function fetchImageUrl(slot, url) {
1148
- const res = await rpcRaw("fetchImageUrl", {
1333
+ const res = await enqueueForSlot(slot, () => rpcRaw("fetchImageUrl", {
1149
1334
  slot,
1150
1335
  url
1151
- });
1336
+ }));
1152
1337
  if (!res) return {
1153
1338
  ok: false,
1154
1339
  error: "no response"
@@ -1167,17 +1352,13 @@ window.__ModuleLoader__.load({
1167
1352
  };
1168
1353
  }
1169
1354
  /**
1170
- * The profile's manifest and config, in one answer. Reads nothing, writes nothing.
1355
+ * One of the profile's wallpapers, addressed by the slot the built-in config names.
1171
1356
  *
1172
1357
  * Raw rather than `rpcCall` for the same reason `fetchImageUrl` is: the node half
1173
- * answers this one with a value that CARRIES its own failure, and the `error`
1174
- * inside it is the only thing worth showing the user — the generic "rpc failed"
1175
- * log would throw that distinction away.
1358
+ * answers this one with a value that CARRIES its own failure, and the `error` inside
1359
+ * it is the only thing worth showing the user — the generic "rpc failed" log would
1360
+ * throw that distinction away.
1176
1361
  */
1177
- async function fetchPresetConfig() {
1178
- return unwrapPreset(await rpcRaw("fetchPresetConfig", {}));
1179
- }
1180
- /** One of the profile's wallpapers, addressed by the slot the config names. */
1181
1362
  async function fetchPresetImage(slot) {
1182
1363
  return unwrapPreset(await rpcRaw("fetchPresetImage", { slot }));
1183
1364
  }
@@ -1202,15 +1383,24 @@ window.__ModuleLoader__.load({
1202
1383
  };
1203
1384
  return v;
1204
1385
  }
1205
- /**
1206
- * Empty the store: the config and every rule image.
1207
- *
1208
- * Only the recommended profile uses this, and only after its download has
1209
- * already succeeded — see `applyRecommended`. Nothing else in the plugin deletes
1210
- * the user's configuration wholesale.
1211
- */
1212
- async function resetStore() {
1213
- return await rpcCall("resetStore", {}) === true;
1386
+ async function adoptRecommended() {
1387
+ const res = await rpcRaw("adoptRecommended", {});
1388
+ if (res === void 0 || res.ok !== true) return {
1389
+ ok: false,
1390
+ rev: cfg.rev
1391
+ };
1392
+ const value = res.value;
1393
+ const base = {
1394
+ ok: value?.ok === true,
1395
+ rev: typeof value?.rev === "number" ? value.rev : cfg.rev
1396
+ };
1397
+ if (base.ok) return base;
1398
+ return {
1399
+ ...base,
1400
+ error: typeof value?.error === "string" ? value.error : void 0,
1401
+ slot: typeof value?.slot === "string" ? value.slot : void 0,
1402
+ wiped: value?.wiped === true
1403
+ };
1214
1404
  }
1215
1405
  /** Host default model — used only when the per-session services are absent. */
1216
1406
  async function readDefaultModel() {
@@ -3682,8 +3872,9 @@ window.__ModuleLoader__.load({
3682
3872
  *
3683
3873
  * Every interval the plugin owns is declared HERE rather than at its call site, so
3684
3874
  * "what does this plugin poll, and how often" has one answer. The scheduler itself
3685
- * lives in ./watchdogs; the questions live in their own modules (boot restore and
3686
- * the theme watchdog in ./appearance, the holiday rollover in ./holiday-status).
3875
+ * lives in ./watchdogs; the questions live in their own modules — the theme
3876
+ * watchdog and the holiday rollover in ./appearance-checks, the delayed boot
3877
+ * restores in ./appearance.
3687
3878
  */
3688
3879
  /** The theme skin / host palette re-assert, once a second while a colour is live. */
3689
3880
  const THEME_WATCH_MS = 1e3;
@@ -3695,6 +3886,16 @@ window.__ModuleLoader__.load({
3695
3886
  const RESTORE_DELAYS_MS = [300, 1500];
3696
3887
  /** The holiday rollover: the answer only moves at Beijing midnight, a whole day wide. */
3697
3888
  const HOLIDAY_WATCH_MS = 3e4;
3889
+ /**
3890
+ * How often the panel checks whether the config on disk moved under it.
3891
+ *
3892
+ * Only matters once agent access is on (the tools are the other writer), so the
3893
+ * check is a conditional no-op the rest of the time: it asks the host for one
3894
+ * number and stops. 3 s is the compromise between noticing an agent's edit while
3895
+ * the user is still looking at the screen and not paying a request per second for
3896
+ * a file that usually has not changed.
3897
+ */
3898
+ const REV_POLL_MS = 3e3;
3698
3899
  //#endregion
3699
3900
  //#region src/client/appearance.ts
3700
3901
  function createAppearance(host) {
@@ -4689,9 +4890,13 @@ window.__ModuleLoader__.load({
4689
4890
  if (url !== null) images[slot] = url;
4690
4891
  }
4691
4892
  return {
4692
- version: 10,
4893
+ version: 11,
4693
4894
  exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
4694
- config: cfg,
4895
+ config: {
4896
+ ...cfg,
4897
+ agentAccess: false,
4898
+ rev: 0
4899
+ },
4695
4900
  images
4696
4901
  };
4697
4902
  }
@@ -4721,20 +4926,24 @@ window.__ModuleLoader__.load({
4721
4926
  * Write a whole theme file into the store: its rules, its wallpapers, nothing
4722
4927
  * else.
4723
4928
  *
4724
- * `wipeFirst` is the entire difference between the two imports, and it belongs to
4725
- * the recommended profile alone: `nextSlot` hands slots out as `m1`, `m2`, … — a
4726
- * counter, not a random token — so a downloaded profile and the store it lands in
4727
- * collide on those names as a matter of course. Emptying first is what makes
4728
- * "replace everything with this" literally true.
4929
+ * The recommended profile no longer comes through here. It used to, as a
4930
+ * `wipeFirst` variant of this function, and that was the shape of a real bug: the
4931
+ * wipe is a separate round trip that empties the config ON DISK, so the write this
4932
+ * function then made named a revision the emptied store had never heard of, was
4933
+ * refused as stale, and adopted the config it had just replaced — the button had to
4934
+ * be pressed twice. Installing the profile is now one node-half operation
4935
+ * (`adoptRecommended`), and this path only ever imports a file over a live store.
4729
4936
  */
4730
- async function applyThemeFile(host, data, wipeFirst) {
4731
- if (typeof data.version === "number" && data.version > 10) return false;
4937
+ async function applyThemeFile(host, data) {
4938
+ if (typeof data.version === "number" && data.version > 11) return false;
4732
4939
  if (typeof data.config !== "object" || data.config === null) return false;
4733
- if (wipeFirst) {
4734
- await resetStore();
4735
- resetConfig();
4736
- }
4737
- adoptConfig(data.config);
4940
+ const ownRev = cfg.rev;
4941
+ const ownAccess = cfg.agentAccess;
4942
+ adoptConfig({
4943
+ ...data.config,
4944
+ agentAccess: ownAccess,
4945
+ rev: ownRev
4946
+ });
4738
4947
  const incoming = data.images ?? {};
4739
4948
  const keep = /* @__PURE__ */ new Set();
4740
4949
  for (const slot of cfg.rules.flatMap(ruleSlots)) {
@@ -4745,10 +4954,10 @@ window.__ModuleLoader__.load({
4745
4954
  writeImage(slot, raw);
4746
4955
  } else {
4747
4956
  setImage(slot, null);
4748
- deleteImage(slot);
4957
+ deleteImageAfterSave(slot);
4749
4958
  }
4750
4959
  }
4751
- for (const slot of Object.keys(incoming)) if (!keep.has(slot) && SLOT_RE.test(slot)) deleteImage(slot);
4960
+ for (const slot of Object.keys(incoming)) if (!keep.has(slot) && SLOT_RE.test(slot)) deleteImageAfterSave(slot);
4752
4961
  persistConfig();
4753
4962
  host.applyActive();
4754
4963
  return true;
@@ -4970,7 +5179,7 @@ window.__ModuleLoader__.load({
4970
5179
  importDone: "已导入配置",
4971
5180
  importFail: "导入失败,文件格式不正确",
4972
5181
  recCardTitle: "推荐配置",
4973
- recCardDesc: "从项目素材库下载作者维护的一整套配置(规则、壁纸与接口设置),一键替换当前的全部内容",
5182
+ recCardDesc: "换上作者维护的一整套配置(规则与界面数值随插件内置,壁纸从图床下载),一键替换当前的全部内容",
4974
5183
  recAdopt: "采用推荐配置",
4975
5184
  recBusy: "正在下载",
4976
5185
  recConfirmTitle: "替换当前的全部配置?",
@@ -4980,13 +5189,12 @@ window.__ModuleLoader__.load({
4980
5189
  recConfirmOk: "删除并替换",
4981
5190
  recConfirmCancel: "取消",
4982
5191
  recDone: "已采用推荐配置",
4983
- recFailNetwork: "推荐配置下载失败,请检查网络后重试",
4984
- recFailNotProfile: "素材库返回的内容不是配置,请稍后重试",
4985
- recFailNewer: "这份推荐配置需要更新的插件版本,未做任何改动",
4986
- recFailTooMany: "这份推荐配置列出的图片数量超出限制,未做任何改动",
5192
+ recFailNetwork: "壁纸下载失败,请检查网络后重试(已下到的会留到下一次)",
4987
5193
  recFailTooLarge: "这份推荐配置体积过大,未做任何改动",
4988
- recFailImage: "这张壁纸没拿到(素材库里缺这个文件):",
5194
+ recFailImage: "这张壁纸没拿到(图床上这个地址没有回应):",
4989
5195
  recFailApply: "未能写入推荐配置,你原来的配置没有被改动",
5196
+ recFailAfterWipe: "推荐配置写了一半就失败了,你原来的规则已经被清空(图片也一起没了)。再点一次「使用推荐配置」可以重来,图片是重新下载的",
5197
+ recFailNoStore: "壁纸没能下载,推荐配置尚未开始,你原来的配置没有被改动",
4990
5198
  storeCardTitle: "图片存储",
4991
5199
  storeCardDesc: "因意外导致的孤儿图片这里可以清掉",
4992
5200
  storeOrphans: "可清理",
@@ -5003,6 +5211,9 @@ window.__ModuleLoader__.load({
5003
5211
  repoLink: "在 GitHub 上打开仓库",
5004
5212
  holidayTitle: "节日特殊背景",
5005
5213
  holidayEnable: "启用节日特殊背景(中秋当天、国庆 10 月 1–7 日)",
5214
+ agentTitle: "允许 AI 修改背景",
5215
+ agentEnable: "打开后,AI 可以调用工具增删改规则、切图、调透明度/颜色/模糊/布局。关闭时这些工具对 AI 完全不可见,不占用上下文。",
5216
+ agentOn: "已开启:AI 可以修改这套外观,改动会即时生效。",
5006
5217
  trTitle: "切换效果",
5007
5218
  trEffect: "切换方式",
5008
5219
  trEffectFade: "淡入淡出",
@@ -5154,7 +5365,7 @@ window.__ModuleLoader__.load({
5154
5365
  importDone: "Profile imported",
5155
5366
  importFail: "Import failed — invalid file",
5156
5367
  recCardTitle: "Recommended profile",
5157
- recCardDesc: "Download the author's own full setup — rules, wallpapers and interface settings — and replace everything with it",
5368
+ recCardDesc: "Install the author's own full setup — rules and interface numbers ship with the plugin, wallpapers are downloaded from the image host — replacing everything you have",
5158
5369
  recAdopt: "Use recommended profile",
5159
5370
  recBusy: "Downloading",
5160
5371
  recConfirmTitle: "Replace your entire profile?",
@@ -5164,13 +5375,12 @@ window.__ModuleLoader__.load({
5164
5375
  recConfirmOk: "Delete and replace",
5165
5376
  recConfirmCancel: "Cancel",
5166
5377
  recDone: "Recommended profile applied",
5167
- recFailNetwork: "Could not download the recommended profile — check your connection and retry",
5168
- recFailNotProfile: "The asset host answered with something that is not a profile — try again later",
5169
- recFailNewer: "This profile needs a newer plugin version. Nothing was changed.",
5170
- recFailTooMany: "The recommended profile names more images than the plugin will fetch. Nothing was changed.",
5378
+ recFailNetwork: "Could not download the wallpapers — check your connection and retry (what arrived is kept for the next try)",
5171
5379
  recFailTooLarge: "The recommended profile is too large. Nothing was changed.",
5172
- recFailImage: "Could not fetch this wallpaper — the asset host has no such file:",
5380
+ recFailImage: "Could not fetch this wallpaper — the image host does not answer for that address:",
5173
5381
  recFailApply: "Could not write the recommended profile — your own is untouched",
5382
+ recFailAfterWipe: "The recommended profile failed halfway, after your own rules had already been cleared (their pictures too). Pressing it again starts over, and the wallpapers are re-downloaded",
5383
+ recFailNoStore: "The wallpapers could not be downloaded, so nothing was installed — your own config is untouched",
5174
5384
  storeCardTitle: "Image storage",
5175
5385
  storeCardDesc: "Orphaned image files left behind by an accident can be cleared here",
5176
5386
  storeOrphans: "Cleanable",
@@ -5187,6 +5397,9 @@ window.__ModuleLoader__.load({
5187
5397
  repoLink: "Open the repository on GitHub",
5188
5398
  holidayTitle: "Holiday backgrounds",
5189
5399
  holidayEnable: "Enable holiday backgrounds (Mid-Autumn day, Oct 1–7)",
5400
+ agentTitle: "Let the AI change the background",
5401
+ agentEnable: "When on, the AI can add, edit and remove rules, switch images, and set opacity, colour, blur and layout. When off these tools are entirely absent from the AI, costing no context.",
5402
+ agentOn: "On: the AI can change this appearance, and edits take effect immediately.",
5190
5403
  trTitle: "Switch effect",
5191
5404
  trEffect: "Transition",
5192
5405
  trEffectFade: "Cross-fade",
@@ -5206,41 +5419,6 @@ window.__ModuleLoader__.load({
5206
5419
  //#endregion
5207
5420
  //#region src/preset.ts
5208
5421
  /**
5209
- * JSON with object keys in a fixed (sorted) order — the same string for the same
5210
- * value, whatever order the object that carried it happened to build its keys in.
5211
- *
5212
- * `JSON.stringify` alone is not an identity for a value that came off the wire and
5213
- * through a sanitizer: key order is an accident of how the object was assembled,
5214
- * and a resume that missed on it would silently re-download everything (the safe
5215
- * direction) while a resume that MATCHED on a value it should not have would be
5216
- * the unsafe one — so this is deliberately total as well: anything JSON cannot
5217
- * carry (a function, a `Symbol`, `undefined`) serializes as `null` and an
5218
- * `undefined` property is skipped, exactly as `JSON.stringify` treats it.
5219
- */
5220
- function canonical(value) {
5221
- if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
5222
- if (value !== null && typeof value === "object") return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
5223
- return JSON.stringify(value) ?? "null";
5224
- }
5225
- /**
5226
- * The identity of one revision of the profile: the manifest's shape version plus
5227
- * the config, in canonical form.
5228
- *
5229
- * The profile is served at ONE version of the assets repository, but a version
5230
- * reference is only as fixed as the tag behind it: re-point the tag and every
5231
- * edge that has not been purged keeps answering with the previous bytes of the
5232
- * same URL. So "the profile" is still not guaranteed to be one fixed thing, and
5233
- * the bytes a previous attempt is holding are only known to belong to what is
5234
- * being installed when that config comes back the same. Order-insensitivity is
5235
- * what lets this be a comparison of VALUES rather than of spellings —
5236
- * `normalizeConfig` (./schema) rebuilds the config it reads, so pinning the key
5237
- * to the exact key order the wire happened to use would make the hold useless
5238
- * the moment anything upstream normalized differently.
5239
- */
5240
- function presetKey(version, config) {
5241
- return `${version}\n${canonical(config)}`;
5242
- }
5243
- /**
5244
5422
  * The bytes of a failed attempt that may be reused for the revision `key` names.
5245
5423
  *
5246
5424
  * A fresh object every time, never the held record itself: the caller fills it in
@@ -5264,6 +5442,147 @@ window.__ModuleLoader__.load({
5264
5442
  function presetMissing(slots, images) {
5265
5443
  return slots.filter((slot) => images[slot] === void 0);
5266
5444
  }
5445
+ /** Every slot the profile installs, in list order — the download set. */
5446
+ const PROFILE_SLOTS = [
5447
+ {
5448
+ slot: "m1",
5449
+ path: "/bfs/new_dyn/e4565384e7e5b2d75756e577f505534b4168597.png"
5450
+ },
5451
+ {
5452
+ slot: "m2",
5453
+ path: "/bfs/new_dyn/aa6e8649701c91e5ac8f43ff28b97a924168597.png"
5454
+ },
5455
+ {
5456
+ slot: "m3",
5457
+ path: "/bfs/new_dyn/5da92b31df5ee2a20b17ec6be353cf5c4168597.png"
5458
+ },
5459
+ {
5460
+ slot: "m4",
5461
+ path: "/bfs/new_dyn/14bb1e53cb2c13ca80bd23e3c1d2a04e4168597.png"
5462
+ },
5463
+ {
5464
+ slot: "m5",
5465
+ path: "/bfs/new_dyn/f51fff40de2d6eaa5a73d177f7bbfa794168597.png"
5466
+ },
5467
+ {
5468
+ slot: "m6",
5469
+ path: "/bfs/new_dyn/cfa71d4e60a2f12aaeec3efe1aa680b34168597.png"
5470
+ },
5471
+ {
5472
+ slot: "m7",
5473
+ path: "/bfs/new_dyn/c09d34b79d9b447d7db98da68b97afc54168597.png"
5474
+ }
5475
+ ].map((i) => i.slot);
5476
+ /**
5477
+ * Identity of this generation of the built-in profile.
5478
+ *
5479
+ * Only one thing reads it: what a failed download KEEPS for the next press
5480
+ * (`PresetHeld.key` in ./preset). Editing the rules or the pictures below must bump
5481
+ * it, or a retry could staple a wallpaper from the old set to a config from the new
5482
+ * one — the same reason the remote profile was compared by value rather than
5483
+ * assumed. Nothing else in the plugin persists it.
5484
+ */
5485
+ const RECOMMENDED_REVISION = "builtin-1";
5486
+ /**
5487
+ * One rule of the profile: the match string, its picture and that picture's color.
5488
+ *
5489
+ * The color is a per-image value (schema 10), and the triple here is the one the
5490
+ * profile has always carried — an HSL accent per model, used to tint the interface
5491
+ * while its wallpaper is on screen.
5492
+ */
5493
+ function profileRule(id, match, slot, color) {
5494
+ return {
5495
+ id,
5496
+ images: [defaultImage(slot, color, "fill")],
5497
+ match,
5498
+ enabled: true,
5499
+ color: null,
5500
+ rotate: defaultRotation()
5501
+ };
5502
+ }
5503
+ /**
5504
+ * The profile, as a fresh config every call.
5505
+ *
5506
+ * ORDER IS THE FALLBACK: matching runs top→bottom and the first usable rule is what
5507
+ * an unmatched model gets, so `deepseek` leads — the model this plugin was written
5508
+ * for. Everything below the rules is the interface numbers a fresh install already
5509
+ * starts on (`DEFAULT_PART_OPACITIES` and friends), which is what makes installing
5510
+ * the profile "add the rules and the pictures" on a clean store.
5511
+ *
5512
+ * Deliberately NOT taken from `freshThemeConfig()`: that one seeds a single BLANK
5513
+ * rule, and this one must not inherit it.
5514
+ */
5515
+ function recommendedConfig() {
5516
+ return {
5517
+ rules: [
5518
+ profileRule("r1", "deepseek", "m3", [
5519
+ 191.88931210132625,
5520
+ .5046198735613555,
5521
+ .82
5522
+ ]),
5523
+ profileRule("r2", "gpt", "m1", [
5524
+ 263.5122459614382,
5525
+ .31076923076923096,
5526
+ .82
5527
+ ]),
5528
+ profileRule("r3", "claude", "m2", [
5529
+ 18.923814597762384,
5530
+ .5486699795381468,
5531
+ .6
5532
+ ]),
5533
+ profileRule("r4", "qwen", "m4", [
5534
+ 26.25977410947,
5535
+ .6228354978354975,
5536
+ .82
5537
+ ]),
5538
+ profileRule("r5", "glm", "m5", [
5539
+ 30.15559737458117,
5540
+ .6552284141830319,
5541
+ .7646280172718907
5542
+ ]),
5543
+ profileRule("r6", "kimi", "m6", [
5544
+ 229.39645097591443,
5545
+ .5752269829617896,
5546
+ .2
5547
+ ]),
5548
+ profileRule("r7", "grok", "m7", [
5549
+ 217.3791348600509,
5550
+ .7702645947081058,
5551
+ .2
5552
+ ])
5553
+ ],
5554
+ opacities: { ...DEFAULT_PART_OPACITIES },
5555
+ transition: defaultTransition(),
5556
+ settingsOpacity: 1,
5557
+ chatTextOpacity: DEFAULT_CHAT_TEXT_OPACITY,
5558
+ trajectoryOpacity: DEFAULT_TRAJECTORY_OPACITY,
5559
+ rightbarOpacity: DEFAULT_RIGHTBAR_OPACITY,
5560
+ pageOpacity: 0,
5561
+ autoExtract: true,
5562
+ holidays: {
5563
+ enabled: true,
5564
+ items: defaultHolidayRules()
5565
+ },
5566
+ agentAccess: false,
5567
+ rev: 0
5568
+ };
5569
+ }
5570
+ /**
5571
+ * The profile as the document both imports consume — the same shape `exportTheme`
5572
+ * writes, so everything downstream (the wipe, the slot loop) is the hand-picked
5573
+ * file's path.
5574
+ *
5575
+ * `images` is filled by the caller as the downloads land, which is what makes a
5576
+ * partially downloaded profile a resumable one rather than an all-or-nothing press.
5577
+ */
5578
+ function recommendedThemeFile(images = {}) {
5579
+ return {
5580
+ version: 11,
5581
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
5582
+ config: recommendedConfig(),
5583
+ images
5584
+ };
5585
+ }
5267
5586
  //#endregion
5268
5587
  //#region src/client/repaint.ts
5269
5588
  /**
@@ -7722,19 +8041,15 @@ window.__ModuleLoader__.load({
7722
8041
  /**
7723
8042
  * The ways the recommended-profile download can fail, mapped to their copy.
7724
8043
  *
7725
- * They are worth telling apart because they ask different things of the user and
7726
- * of whoever maintains the assets repository: nothing answering is a network to
7727
- * retry, a mirror that answered with something that is not a profile is not
7728
- * going to start working on a retry, a profile written for a newer plugin is a
7729
- * "update this build", and too many images is a profile nobody should install.
7730
- * A failure that names a SLOT is a fifth case and is rendered separately — that
7731
- * one is the author's file naming, and only the slot tells them which.
8044
+ * Two cases, because the profile is now only PICTURES: the rules and their numbers
8045
+ * are compiled in, so there is no manifest to be malformed, no shape to be too new
8046
+ * and no config to name too many slots. What is left is the network ("retry") and
8047
+ * the cap on the total ("nobody should install this"). A failure that names a SLOT
8048
+ * is rendered separately — that one is about one BFS address, and the slot is what
8049
+ * says which.
7732
8050
  */
7733
8051
  const REC_ERROR_KEYS = {
7734
8052
  "download failed": "recFailNetwork",
7735
- "not a profile": "recFailNotProfile",
7736
- "newer version": "recFailNewer",
7737
- "too many images": "recFailTooMany",
7738
8053
  "too large": "recFailTooLarge"
7739
8054
  };
7740
8055
  function ProfilePage({ p, notify }) {
@@ -7742,6 +8057,7 @@ window.__ModuleLoader__.load({
7742
8057
  const importRef = (0, react.useRef)(null);
7743
8058
  p.useStore((s) => s.rev + s.rulesRev);
7744
8059
  const holidays = cfg.holidays;
8060
+ const agentAccess = cfg.agentAccess;
7745
8061
  const tr = cfg.transition;
7746
8062
  const store = p.useStore((s) => s);
7747
8063
  const [text, setText] = (0, react.useState)("");
@@ -7825,7 +8141,7 @@ window.__ModuleLoader__.load({
7825
8141
  });
7826
8142
  setRecBusy(false);
7827
8143
  if (!res.ok) {
7828
- notify(res.slot === void 0 ? t(REC_ERROR_KEYS[res.error] ?? "recFailNetwork") : `${t("recFailImage")} ${res.slot}`, false);
8144
+ notify(res.slot === void 0 ? `${t(REC_ERROR_KEYS[res.error] ?? "recFailNetwork")} ${t("recFailNoStore")}` : `${t("recFailImage")} ${res.slot} ${t("recFailNoStore")}`, false);
7829
8145
  return;
7830
8146
  }
7831
8147
  setPending(res.preset);
@@ -7834,10 +8150,14 @@ window.__ModuleLoader__.load({
7834
8150
  const preset = pending;
7835
8151
  if (preset === null) return;
7836
8152
  setRecBusy(true);
7837
- const ok = await p.applyRecommended(preset);
8153
+ const res = await p.applyRecommended(preset);
7838
8154
  setRecBusy(false);
7839
8155
  setPending(null);
7840
- notify(ok ? t("recDone") : t("recFailApply"), ok);
8156
+ if (res.ok) {
8157
+ notify(t("recDone"));
8158
+ return;
8159
+ }
8160
+ notify(res.wiped ? t("recFailAfterWipe") : t("recFailApply"), false);
7841
8161
  };
7842
8162
  const [usage, setUsage] = (0, react.useState)(null);
7843
8163
  /** Distinguishes "not asked yet" from "asked, and the host cannot answer". */
@@ -7898,6 +8218,30 @@ window.__ModuleLoader__.load({
7898
8218
  })
7899
8219
  ]
7900
8220
  }),
8221
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
8222
+ className: "dab-card dab-rise",
8223
+ style: { "--d": 1 },
8224
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
8225
+ className: "dab-chip-row",
8226
+ style: { alignItems: "center" },
8227
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
8228
+ type: "button",
8229
+ className: `dab-toggle${agentAccess ? " is-on" : ""}`,
8230
+ role: "switch",
8231
+ "aria-checked": agentAccess,
8232
+ title: t("agentEnable"),
8233
+ onClick: () => {
8234
+ const next = !agentAccess;
8235
+ p.setAgentAccess(next);
8236
+ if (next) notify(t("agentOn"));
8237
+ },
8238
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dab-toggle-knob" })
8239
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
8240
+ className: "dab-holiday-label",
8241
+ children: t("agentTitle")
8242
+ })]
8243
+ })
8244
+ }),
7901
8245
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
7902
8246
  className: "dab-card dab-rise",
7903
8247
  style: { "--d": 1 },
@@ -8418,12 +8762,12 @@ window.__ModuleLoader__.load({
8418
8762
  /**
8419
8763
  * Ceiling on what one recommended profile may add up to, across every wallpaper.
8420
8764
  *
8421
- * The node half caps each file and the number of files, but it answers one
8422
- * independent call at a time and never sees a running total — the client is the
8423
- * only side that does, so the total is bounded here. The real profile is 2.2 MB
8424
- * of images across seven of them, so this is generous by design: it exists to
8425
- * stop a config that names sixty-four eight-megabyte slots from turning one
8426
- * button press into half a gigabyte, not to second-guess the author.
8765
+ * The node half caps ONE file, but it answers one independent call at a time and
8766
+ * never sees a running total — the client is the only side that does, so the total
8767
+ * is bounded here. The real profile is 2.2 MB of images across seven of them, so
8768
+ * this is generous by design: it exists so that a build which grew a wrong URL
8769
+ * (or an edge that served an upload instead of the transform) cannot turn one
8770
+ * button press into half a gigabyte.
8427
8771
  */
8428
8772
  const PRESET_TOTAL_MAX = 33554432;
8429
8773
  /**
@@ -8434,7 +8778,8 @@ window.__ModuleLoader__.load({
8434
8778
  * CDN edge was still cold). What is being paid is per-request latency, and the
8435
8779
  * host speaks HTTP/2 — the bytes are identical either way and the connection is
8436
8780
  * reused — so overlapping them buys wall-clock time and risks nothing but a
8437
- * little contention.
8781
+ * little contention. The same holds for the Bilibili edges the pictures come from
8782
+ * now (see `PROFILE_HOSTS` in ../profile).
8438
8783
  *
8439
8784
  * Four rather than all seven: a burst that wide competes for the same bandwidth
8440
8785
  * and makes the per-file timeout harder to reason about, and four already hides
@@ -8669,6 +9014,22 @@ window.__ModuleLoader__.load({
8669
9014
  };
8670
9015
  schedule.add(themeWatchdog(checkHost));
8671
9016
  schedule.add(holidayRollover(checkHost));
9017
+ schedule.add({
9018
+ name: "rev",
9019
+ everyMs: REV_POLL_MS,
9020
+ run() {
9021
+ if (!cfg.agentAccess) return;
9022
+ (async () => {
9023
+ const rev = await readRev();
9024
+ if (rev === null || rev === cfg.rev) return;
9025
+ const persisted = await loadPersisted();
9026
+ if (persisted === null) return;
9027
+ adoptConfig(persisted.config);
9028
+ for (const slot of cfg.rules.flatMap(ruleSlots)) if (displayImageOf(slot) === null) await loadSlot(slot);
9029
+ applyActive();
9030
+ })();
9031
+ }
9032
+ });
8672
9033
  (async () => {
8673
9034
  const persisted = await loadPersisted();
8674
9035
  if (persisted !== null) {
@@ -8680,6 +9041,13 @@ window.__ModuleLoader__.load({
8680
9041
  }
8681
9042
  applyActive();
8682
9043
  })();
9044
+ setConflictHandler((config) => {
9045
+ adoptConfig(config);
9046
+ (async () => {
9047
+ for (const slot of cfg.rules.flatMap(ruleSlots)) if (displayImageOf(slot) === null) await loadSlot(slot);
9048
+ applyActive();
9049
+ })();
9050
+ });
8683
9051
  ctx.effect(() => () => {
8684
9052
  teardownWp();
8685
9053
  }, "dsh-background-by-model: wp cleanup");
@@ -8778,7 +9146,7 @@ window.__ModuleLoader__.load({
8778
9146
  for (const slot of ruleSlots(rule)) {
8779
9147
  if (stillUsed.has(slot)) continue;
8780
9148
  setImage(slot, null);
8781
- deleteImage(slot);
9149
+ deleteImageAfterSave(slot);
8782
9150
  }
8783
9151
  }
8784
9152
  rulesRev++;
@@ -8836,7 +9204,7 @@ window.__ModuleLoader__.load({
8836
9204
  persistConfig();
8837
9205
  if (!takenSlots().has(slot)) {
8838
9206
  setImage(slot, null);
8839
- deleteImage(slot);
9207
+ deleteImageAfterSave(slot);
8840
9208
  }
8841
9209
  repaintIfMoved(id, winner);
8842
9210
  },
@@ -9040,6 +9408,11 @@ window.__ModuleLoader__.load({
9040
9408
  sync();
9041
9409
  saveConfig();
9042
9410
  },
9411
+ setAgentAccess: (v) => {
9412
+ cfg.agentAccess = v;
9413
+ sync();
9414
+ persistConfig();
9415
+ },
9043
9416
  setHolidaysEnabled: (v) => {
9044
9417
  cfg.holidays.enabled = v;
9045
9418
  persistConfig();
@@ -9054,36 +9427,34 @@ window.__ModuleLoader__.load({
9054
9427
  try {
9055
9428
  const data = JSON.parse(await file.text());
9056
9429
  if (!data || typeof data !== "object") return false;
9057
- return await applyThemeFile({ applyActive }, data, false);
9430
+ return await applyThemeFile({ applyActive }, data);
9058
9431
  } catch {
9059
9432
  return false;
9060
9433
  }
9061
9434
  },
9062
9435
  /**
9063
- * Fetch the project's recommended profile. Downloads only — applying it is
9064
- * separate, because the page has to ask before anything is destroyed and the
9065
- * question should describe what is actually in hand.
9436
+ * Fetch the recommended profile's wallpapers. Downloads only — applying them
9437
+ * is separate, because the page has to ask before anything is destroyed and
9438
+ * the question should describe what is actually in hand.
9066
9439
  *
9067
- * The profile is a DIRECTORY, so this is a pool rather than one call: the
9068
- * config names the slots and each wallpaper is one request, up to
9069
- * `PRESET_CONCURRENCY` at a time. Each file keeps its own mirror fallback,
9070
- * retry rounds (the node half's business, ./preset), validation and slot name
9071
- * in a failure.
9440
+ * The RULES are not fetched at all: they are compiled into the bundle
9441
+ * (./profile), so this is a set of picture transfers and nothing else. That
9442
+ * is why there is no manifest round, no shape gate and no "not a profile"
9443
+ * failure left — nothing remote can decide what this installs.
9444
+ *
9445
+ * Each slot is one request, up to `PRESET_CONCURRENCY` at a time, and each
9446
+ * file keeps its own host fallback, retry rounds (the node half's business,
9447
+ * ./preset), validation and slot name in a failure.
9072
9448
  *
9073
9449
  * STRICT: the first wallpaper that cannot be had stops the queue. What had
9074
9450
  * already arrived is kept for the next press (`presetHeld` below) — the
9075
9451
  * difference between retrying one file and retrying the lot. Nothing is
9076
- * touched here, so "the author renamed a file" arrives as an error naming the
9077
- * file rather than as a profile quietly missing a picture.
9452
+ * touched here, so a wrong URL arrives as an error naming the slot rather
9453
+ * than as a profile quietly missing a picture.
9078
9454
  */
9079
9455
  fetchRecommended: async (onProgress) => {
9080
- const head = await fetchPresetConfig();
9081
- if (!head.ok) return {
9082
- ok: false,
9083
- error: head.error
9084
- };
9085
- const slots = head.slots;
9086
- const key = presetKey(head.version, head.config);
9456
+ const slots = PROFILE_SLOTS;
9457
+ const key = RECOMMENDED_REVISION;
9087
9458
  const images = presetResume(presetHeld, key);
9088
9459
  let bytes = 0;
9089
9460
  for (const url of Object.values(images)) bytes += url.length;
@@ -9131,18 +9502,46 @@ window.__ModuleLoader__.load({
9131
9502
  presetHeld = null;
9132
9503
  return {
9133
9504
  ok: true,
9134
- preset: {
9135
- version: head.version,
9136
- exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
9137
- config: head.config,
9138
- images
9139
- }
9505
+ preset: recommendedThemeFile(images)
9140
9506
  };
9141
9507
  },
9142
9508
  /** Replace EVERYTHING with a downloaded profile: empty the store, then write it. */
9143
- applyRecommended: async (preset) => {
9144
- if (!canWriteConfig()) return false;
9145
- return await applyThemeFile({ applyActive }, preset, true);
9509
+ applyRecommended: async (_preset) => {
9510
+ if (!canWriteConfig()) return {
9511
+ ok: false,
9512
+ wiped: false
9513
+ };
9514
+ const res = await adoptRecommended();
9515
+ if (!res.ok) {
9516
+ if (res.wiped === true) {
9517
+ const after = await loadPersisted();
9518
+ if (after !== null) {
9519
+ adoptConfig(after.config);
9520
+ clearImages();
9521
+ applyActive();
9522
+ }
9523
+ }
9524
+ return {
9525
+ ok: false,
9526
+ error: res.error,
9527
+ slot: res.slot,
9528
+ wiped: res.wiped === true
9529
+ };
9530
+ }
9531
+ const persisted = await loadPersisted();
9532
+ if (persisted === null) return {
9533
+ ok: false,
9534
+ error: "the profile was installed but could not be read back",
9535
+ wiped: true
9536
+ };
9537
+ adoptConfig(persisted.config);
9538
+ clearImages();
9539
+ for (const slot of cfg.rules.flatMap(ruleSlots)) await loadSlot(slot);
9540
+ applyActive();
9541
+ return {
9542
+ ok: true,
9543
+ wiped: false
9544
+ };
9146
9545
  },
9147
9546
  readStoreUsage: () => readStoreUsage(cfg.rules.flatMap(ruleSlots)),
9148
9547
  pruneOrphanImages: async () => {
@@ -9166,7 +9565,10 @@ window.__ModuleLoader__.load({
9166
9565
  ctx.effect(() => () => {
9167
9566
  disposeThemeResets();
9168
9567
  }, "dsh-background-by-model: theme resets watch");
9169
- const onPageHide = () => flushSave();
9568
+ const onPageHide = () => {
9569
+ flushSave();
9570
+ drainDeferredDeletes();
9571
+ };
9170
9572
  window.addEventListener("pagehide", onPageHide);
9171
9573
  ctx.effect(() => () => window.removeEventListener("pagehide", onPageHide), "dsh-background-by-model: pagehide flush");
9172
9574
  }