dsh-background-by-model 0.11.0 → 0.12.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/client.js CHANGED
@@ -474,7 +474,9 @@ window.__ModuleLoader__.load({
474
474
  holidays: {
475
475
  enabled: false,
476
476
  items: defaultHolidayRules()
477
- }
477
+ },
478
+ agentAccess: false,
479
+ rev: 0
478
480
  };
479
481
  }
480
482
  /** Slot names reach the filesystem, so they are strictly whitelisted. */
@@ -740,7 +742,9 @@ window.__ModuleLoader__.load({
740
742
  rightbarOpacity: normalizeRightbarOpacity(r.rightbarOpacity),
741
743
  pageOpacity: clamp01(r.pageOpacity, 0),
742
744
  autoExtract: r.autoExtract !== false,
743
- 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
744
748
  };
745
749
  }
746
750
  freshThemeConfig();
@@ -1029,12 +1033,23 @@ window.__ModuleLoader__.load({
1029
1033
  }
1030
1034
  rotIndex = 0;
1031
1035
  }
1032
- function resetConfig() {
1033
- cfg = freshThemeConfig();
1034
- clearImages();
1035
- activeRuleId = null;
1036
- activeMatched = false;
1037
- 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);
1038
1053
  }
1039
1054
  //#endregion
1040
1055
  //#region src/client/rpc.ts
@@ -1083,6 +1098,124 @@ window.__ModuleLoader__.load({
1083
1098
  }
1084
1099
  const SAVE_DEBOUNCE_MS = 250;
1085
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. */
1086
1219
  function saveConfig() {
1087
1220
  if (!canWriteConfig()) {
1088
1221
  holdWrite();
@@ -1091,7 +1224,7 @@ window.__ModuleLoader__.load({
1091
1224
  if (saveTimer !== void 0) window.clearTimeout(saveTimer);
1092
1225
  saveTimer = window.setTimeout(() => {
1093
1226
  saveTimer = void 0;
1094
- rpcCall("writeConfig", { config: cfg });
1227
+ persistAndResolve();
1095
1228
  }, SAVE_DEBOUNCE_MS);
1096
1229
  }
1097
1230
  function flushSave() {
@@ -1102,7 +1235,7 @@ window.__ModuleLoader__.load({
1102
1235
  holdWrite();
1103
1236
  return;
1104
1237
  }
1105
- rpcCall("writeConfig", { config: cfg });
1238
+ persistAndResolve();
1106
1239
  }
1107
1240
  /** Persist the current config immediately (no debounce). */
1108
1241
  function persistConfig() {
@@ -1110,7 +1243,19 @@ window.__ModuleLoader__.load({
1110
1243
  holdWrite();
1111
1244
  return;
1112
1245
  }
1113
- 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);
1114
1259
  }
1115
1260
  /** Load the persisted config plus the list of stored image slots. */
1116
1261
  async function loadPersisted() {
@@ -1119,7 +1264,7 @@ window.__ModuleLoader__.load({
1119
1264
  const d = data;
1120
1265
  const slots = Array.isArray(d.slots) ? d.slots.filter((s) => typeof s === "string") : [];
1121
1266
  const schema = typeof d.schema === "number" && isFinite(d.schema) ? d.schema : null;
1122
- hostShape = typeof schema === "number" && schema >= 10 ? "current" : "legacy";
1267
+ hostShape = typeof schema === "number" && schema >= 11 ? "current" : "legacy";
1123
1268
  if (hostShape === "legacy") holdWrite();
1124
1269
  return {
1125
1270
  config: d.config,
@@ -1127,30 +1272,68 @@ window.__ModuleLoader__.load({
1127
1272
  schema
1128
1273
  };
1129
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
+ }
1130
1313
  /** Read one rule image as a data URL (null when the slot is empty). */
1131
1314
  async function readImage(slot) {
1132
- const data = await rpcCall("readImage", { slot });
1315
+ const data = await enqueueForSlot(slot, () => rpcCall("readImage", { slot }));
1133
1316
  if (data === null || typeof data !== "object") return null;
1134
1317
  const url = data.dataUrl;
1135
1318
  return typeof url === "string" ? url : null;
1136
1319
  }
1137
1320
  /** Persist (or clear) one rule image; one-shot, no debounce. */
1138
1321
  async function writeImage(slot, dataUrl) {
1139
- return await rpcCall("writeImage", {
1322
+ return await enqueueForSlot(slot, () => rpcCall("writeImage", {
1140
1323
  slot,
1141
1324
  dataUrl
1142
- }) === true;
1325
+ })) === true;
1143
1326
  }
1144
1327
  /** Remove one rule image from disk. */
1145
1328
  async function deleteImage(slot) {
1146
- return await rpcCall("deleteImage", { slot }) === true;
1329
+ return await enqueueForSlot(slot, () => rpcCall("deleteImage", { slot })) === true;
1147
1330
  }
1148
1331
  /** Download an image from a network URL into one slot, replacing its bytes. */
1149
1332
  async function fetchImageUrl(slot, url) {
1150
- const res = await rpcRaw("fetchImageUrl", {
1333
+ const res = await enqueueForSlot(slot, () => rpcRaw("fetchImageUrl", {
1151
1334
  slot,
1152
1335
  url
1153
- });
1336
+ }));
1154
1337
  if (!res) return {
1155
1338
  ok: false,
1156
1339
  error: "no response"
@@ -1200,15 +1383,24 @@ window.__ModuleLoader__.load({
1200
1383
  };
1201
1384
  return v;
1202
1385
  }
1203
- /**
1204
- * Empty the store: the config and every rule image.
1205
- *
1206
- * Only the recommended profile uses this, and only after its download has
1207
- * already succeeded — see `applyRecommended`. Nothing else in the plugin deletes
1208
- * the user's configuration wholesale.
1209
- */
1210
- async function resetStore() {
1211
- 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
+ };
1212
1404
  }
1213
1405
  /** Host default model — used only when the per-session services are absent. */
1214
1406
  async function readDefaultModel() {
@@ -3680,8 +3872,9 @@ window.__ModuleLoader__.load({
3680
3872
  *
3681
3873
  * Every interval the plugin owns is declared HERE rather than at its call site, so
3682
3874
  * "what does this plugin poll, and how often" has one answer. The scheduler itself
3683
- * lives in ./watchdogs; the questions live in their own modules (boot restore and
3684
- * 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.
3685
3878
  */
3686
3879
  /** The theme skin / host palette re-assert, once a second while a colour is live. */
3687
3880
  const THEME_WATCH_MS = 1e3;
@@ -3693,6 +3886,16 @@ window.__ModuleLoader__.load({
3693
3886
  const RESTORE_DELAYS_MS = [300, 1500];
3694
3887
  /** The holiday rollover: the answer only moves at Beijing midnight, a whole day wide. */
3695
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;
3696
3899
  //#endregion
3697
3900
  //#region src/client/appearance.ts
3698
3901
  function createAppearance(host) {
@@ -4687,9 +4890,13 @@ window.__ModuleLoader__.load({
4687
4890
  if (url !== null) images[slot] = url;
4688
4891
  }
4689
4892
  return {
4690
- version: 10,
4893
+ version: 11,
4691
4894
  exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
4692
- config: cfg,
4895
+ config: {
4896
+ ...cfg,
4897
+ agentAccess: false,
4898
+ rev: 0
4899
+ },
4693
4900
  images
4694
4901
  };
4695
4902
  }
@@ -4719,20 +4926,24 @@ window.__ModuleLoader__.load({
4719
4926
  * Write a whole theme file into the store: its rules, its wallpapers, nothing
4720
4927
  * else.
4721
4928
  *
4722
- * `wipeFirst` is the entire difference between the two imports, and it belongs to
4723
- * the recommended profile alone: `nextSlot` hands slots out as `m1`, `m2`, … — a
4724
- * counter, not a random token — so a downloaded profile and the store it lands in
4725
- * collide on those names as a matter of course. Emptying first is what makes
4726
- * "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.
4727
4936
  */
4728
- async function applyThemeFile(host, data, wipeFirst) {
4729
- 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;
4730
4939
  if (typeof data.config !== "object" || data.config === null) return false;
4731
- if (wipeFirst) {
4732
- await resetStore();
4733
- resetConfig();
4734
- }
4735
- 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
+ });
4736
4947
  const incoming = data.images ?? {};
4737
4948
  const keep = /* @__PURE__ */ new Set();
4738
4949
  for (const slot of cfg.rules.flatMap(ruleSlots)) {
@@ -4743,10 +4954,10 @@ window.__ModuleLoader__.load({
4743
4954
  writeImage(slot, raw);
4744
4955
  } else {
4745
4956
  setImage(slot, null);
4746
- deleteImage(slot);
4957
+ deleteImageAfterSave(slot);
4747
4958
  }
4748
4959
  }
4749
- 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);
4750
4961
  persistConfig();
4751
4962
  host.applyActive();
4752
4963
  return true;
@@ -4982,6 +5193,8 @@ window.__ModuleLoader__.load({
4982
5193
  recFailTooLarge: "这份推荐配置体积过大,未做任何改动",
4983
5194
  recFailImage: "这张壁纸没拿到(图床上这个地址没有回应):",
4984
5195
  recFailApply: "未能写入推荐配置,你原来的配置没有被改动",
5196
+ recFailAfterWipe: "推荐配置写了一半就失败了,你原来的规则已经被清空(图片也一起没了)。再点一次「使用推荐配置」可以重来,图片是重新下载的",
5197
+ recFailNoStore: "壁纸没能下载,推荐配置尚未开始,你原来的配置没有被改动",
4985
5198
  storeCardTitle: "图片存储",
4986
5199
  storeCardDesc: "因意外导致的孤儿图片这里可以清掉",
4987
5200
  storeOrphans: "可清理",
@@ -4998,6 +5211,9 @@ window.__ModuleLoader__.load({
4998
5211
  repoLink: "在 GitHub 上打开仓库",
4999
5212
  holidayTitle: "节日特殊背景",
5000
5213
  holidayEnable: "启用节日特殊背景(中秋当天、国庆 10 月 1–7 日)",
5214
+ agentTitle: "允许 AI 修改背景",
5215
+ agentEnable: "打开后,AI 可以调用工具增删改规则、切图、调透明度/颜色/模糊/布局。关闭时这些工具对 AI 完全不可见,不占用上下文。",
5216
+ agentOn: "已开启:AI 可以修改这套外观,改动会即时生效。",
5001
5217
  trTitle: "切换效果",
5002
5218
  trEffect: "切换方式",
5003
5219
  trEffectFade: "淡入淡出",
@@ -5163,6 +5379,8 @@ window.__ModuleLoader__.load({
5163
5379
  recFailTooLarge: "The recommended profile is too large. Nothing was changed.",
5164
5380
  recFailImage: "Could not fetch this wallpaper — the image host does not answer for that address:",
5165
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",
5166
5384
  storeCardTitle: "Image storage",
5167
5385
  storeCardDesc: "Orphaned image files left behind by an accident can be cleared here",
5168
5386
  storeOrphans: "Cleanable",
@@ -5179,6 +5397,9 @@ window.__ModuleLoader__.load({
5179
5397
  repoLink: "Open the repository on GitHub",
5180
5398
  holidayTitle: "Holiday backgrounds",
5181
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.",
5182
5403
  trTitle: "Switch effect",
5183
5404
  trEffect: "Transition",
5184
5405
  trEffectFade: "Cross-fade",
@@ -5341,7 +5562,9 @@ window.__ModuleLoader__.load({
5341
5562
  holidays: {
5342
5563
  enabled: true,
5343
5564
  items: defaultHolidayRules()
5344
- }
5565
+ },
5566
+ agentAccess: false,
5567
+ rev: 0
5345
5568
  };
5346
5569
  }
5347
5570
  /**
@@ -5354,7 +5577,7 @@ window.__ModuleLoader__.load({
5354
5577
  */
5355
5578
  function recommendedThemeFile(images = {}) {
5356
5579
  return {
5357
- version: 10,
5580
+ version: 11,
5358
5581
  exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
5359
5582
  config: recommendedConfig(),
5360
5583
  images
@@ -7834,6 +8057,7 @@ window.__ModuleLoader__.load({
7834
8057
  const importRef = (0, react.useRef)(null);
7835
8058
  p.useStore((s) => s.rev + s.rulesRev);
7836
8059
  const holidays = cfg.holidays;
8060
+ const agentAccess = cfg.agentAccess;
7837
8061
  const tr = cfg.transition;
7838
8062
  const store = p.useStore((s) => s);
7839
8063
  const [text, setText] = (0, react.useState)("");
@@ -7917,7 +8141,7 @@ window.__ModuleLoader__.load({
7917
8141
  });
7918
8142
  setRecBusy(false);
7919
8143
  if (!res.ok) {
7920
- 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);
7921
8145
  return;
7922
8146
  }
7923
8147
  setPending(res.preset);
@@ -7926,10 +8150,14 @@ window.__ModuleLoader__.load({
7926
8150
  const preset = pending;
7927
8151
  if (preset === null) return;
7928
8152
  setRecBusy(true);
7929
- const ok = await p.applyRecommended(preset);
8153
+ const res = await p.applyRecommended(preset);
7930
8154
  setRecBusy(false);
7931
8155
  setPending(null);
7932
- 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);
7933
8161
  };
7934
8162
  const [usage, setUsage] = (0, react.useState)(null);
7935
8163
  /** Distinguishes "not asked yet" from "asked, and the host cannot answer". */
@@ -7990,6 +8218,30 @@ window.__ModuleLoader__.load({
7990
8218
  })
7991
8219
  ]
7992
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
+ }),
7993
8245
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
7994
8246
  className: "dab-card dab-rise",
7995
8247
  style: { "--d": 1 },
@@ -8762,6 +9014,22 @@ window.__ModuleLoader__.load({
8762
9014
  };
8763
9015
  schedule.add(themeWatchdog(checkHost));
8764
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
+ });
8765
9033
  (async () => {
8766
9034
  const persisted = await loadPersisted();
8767
9035
  if (persisted !== null) {
@@ -8773,6 +9041,13 @@ window.__ModuleLoader__.load({
8773
9041
  }
8774
9042
  applyActive();
8775
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
+ });
8776
9051
  ctx.effect(() => () => {
8777
9052
  teardownWp();
8778
9053
  }, "dsh-background-by-model: wp cleanup");
@@ -8871,7 +9146,7 @@ window.__ModuleLoader__.load({
8871
9146
  for (const slot of ruleSlots(rule)) {
8872
9147
  if (stillUsed.has(slot)) continue;
8873
9148
  setImage(slot, null);
8874
- deleteImage(slot);
9149
+ deleteImageAfterSave(slot);
8875
9150
  }
8876
9151
  }
8877
9152
  rulesRev++;
@@ -8929,7 +9204,7 @@ window.__ModuleLoader__.load({
8929
9204
  persistConfig();
8930
9205
  if (!takenSlots().has(slot)) {
8931
9206
  setImage(slot, null);
8932
- deleteImage(slot);
9207
+ deleteImageAfterSave(slot);
8933
9208
  }
8934
9209
  repaintIfMoved(id, winner);
8935
9210
  },
@@ -9133,6 +9408,11 @@ window.__ModuleLoader__.load({
9133
9408
  sync();
9134
9409
  saveConfig();
9135
9410
  },
9411
+ setAgentAccess: (v) => {
9412
+ cfg.agentAccess = v;
9413
+ sync();
9414
+ persistConfig();
9415
+ },
9136
9416
  setHolidaysEnabled: (v) => {
9137
9417
  cfg.holidays.enabled = v;
9138
9418
  persistConfig();
@@ -9147,7 +9427,7 @@ window.__ModuleLoader__.load({
9147
9427
  try {
9148
9428
  const data = JSON.parse(await file.text());
9149
9429
  if (!data || typeof data !== "object") return false;
9150
- return await applyThemeFile({ applyActive }, data, false);
9430
+ return await applyThemeFile({ applyActive }, data);
9151
9431
  } catch {
9152
9432
  return false;
9153
9433
  }
@@ -9226,9 +9506,42 @@ window.__ModuleLoader__.load({
9226
9506
  };
9227
9507
  },
9228
9508
  /** Replace EVERYTHING with a downloaded profile: empty the store, then write it. */
9229
- applyRecommended: async (preset) => {
9230
- if (!canWriteConfig()) return false;
9231
- 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
+ };
9232
9545
  },
9233
9546
  readStoreUsage: () => readStoreUsage(cfg.rules.flatMap(ruleSlots)),
9234
9547
  pruneOrphanImages: async () => {
@@ -9252,7 +9565,10 @@ window.__ModuleLoader__.load({
9252
9565
  ctx.effect(() => () => {
9253
9566
  disposeThemeResets();
9254
9567
  }, "dsh-background-by-model: theme resets watch");
9255
- const onPageHide = () => flushSave();
9568
+ const onPageHide = () => {
9569
+ flushSave();
9570
+ drainDeferredDeletes();
9571
+ };
9256
9572
  window.addEventListener("pagehide", onPageHide);
9257
9573
  ctx.effect(() => () => window.removeEventListener("pagehide", onPageHide), "dsh-background-by-model: pagehide flush");
9258
9574
  }