pinokiod 8.0.78 → 8.0.79

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.
@@ -6,7 +6,8 @@
6
6
  href: <link location to open>,
7
7
  target: <target for window.open()>,
8
8
  features: <windowFeatures>,
9
- audio: <play audio if true>,
9
+ type: <notification type>,
10
+ silent: <disable the notification sound if true>,
10
11
  }
11
12
  }
12
13
  (*/
@@ -1,7 +1,6 @@
1
1
  const fs = require("fs")
2
2
  const path = require("path")
3
3
  const {
4
- SIZE_THRESHOLD,
5
4
  isCandidateFileSize,
6
5
  SHA256_RE,
7
6
  ENTRY_BATCH_SIZE
@@ -276,7 +275,7 @@ class AutomaticScans {
276
275
  if (!collected.size) this.discardChangedPaths(app)
277
276
  }
278
277
 
279
- async candidatePolicy() {
278
+ async candidatePolicy(app = null) {
280
279
  const cached = this.vault.lastScanCache &&
281
280
  typeof this.vault.lastScanCache.get === "function"
282
281
  ? this.vault.lastScanCache.get("")
@@ -284,13 +283,13 @@ class AutomaticScans {
284
283
  const scan = cached || (this.vault.registry
285
284
  ? await this.vault.registry.scanFor()
286
285
  : null)
287
- const threshold = scan ? Number(scan.candidate_min_bytes) : NaN
286
+ const threshold = app
287
+ ? await this.vault.candidateSizeForApp(app)
288
+ : await this.vault.candidateSizeSetting(null)
288
289
  const hasLinkableDevices = !!(
289
290
  scan && Array.isArray(scan.linkable_devices))
290
291
  return {
291
- threshold: Number.isFinite(threshold) && threshold >= 0
292
- ? threshold
293
- : SIZE_THRESHOLD,
292
+ threshold,
294
293
  linkability_known: hasLinkableDevices,
295
294
  linkable_devices: new Set(hasLinkableDevices
296
295
  ? scan.linkable_devices
@@ -1039,7 +1038,7 @@ class AutomaticScans {
1039
1038
  unstable_hashes: 0
1040
1039
  }
1041
1040
  markStage("policy-read")
1042
- const policy = await this.candidatePolicy()
1041
+ const policy = await this.candidatePolicy(app)
1043
1042
  const threshold = await this.candidateThreshold(policy)
1044
1043
  const linkableDevices = policy.linkable_devices
1045
1044
  const paths = [...new Set(active.paths || [])].filter((filePath) =>
@@ -1899,6 +1899,25 @@ class Vault {
1899
1899
  }
1900
1900
  return this.runMutation(() =>
1901
1901
  this.removeExternalSource(payload.source_id))
1902
+ case "set_candidate_size": {
1903
+ if (payload.scope_id != null &&
1904
+ typeof payload.scope_id !== "string") {
1905
+ return { error: "Choose a valid scan scope." }
1906
+ }
1907
+ const scopeId = typeof payload.scope_id === "string" && payload.scope_id
1908
+ ? payload.scope_id
1909
+ : null
1910
+ if (scopeId) {
1911
+ const source = this.scanSource(scopeId)
1912
+ if (!source || source.kind !== "app") {
1913
+ return { error: "That app is no longer available." }
1914
+ }
1915
+ }
1916
+ return this.setCandidateSizeSetting(
1917
+ scopeId,
1918
+ payload.candidate_size
1919
+ )
1920
+ }
1902
1921
  case "scan": {
1903
1922
  const scanSource = payload.scope_id
1904
1923
  ? this.scanSource(payload.scope_id)
@@ -1913,22 +1932,21 @@ class Vault {
1913
1932
  code: "global_scan_required"
1914
1933
  }
1915
1934
  }
1916
- let threshold = this.sizeThreshold
1917
- if (payload.candidate_size != null) {
1918
- if (!CANDIDATE_SIZE_OPTIONS.includes(payload.candidate_size)) {
1919
- return { error: "Choose a valid minimum file size." }
1920
- }
1921
- threshold = payload.candidate_size
1922
- }
1935
+ const settingScopeId = scanSource && scanSource.kind === "app"
1936
+ ? scanSource.id
1937
+ : null
1938
+ const threshold = await this.candidateSizeSetting(settingScopeId)
1923
1939
  return this.startScan(payload.scope_id || null, threshold)
1924
1940
  }
1925
1941
  case "cancel_scan":
1926
1942
  return this.cancelScan()
1927
- case "find_folders":
1943
+ case "find_folders": {
1944
+ const threshold = await this.candidateSizeSetting(null)
1928
1945
  return this.startFolderDiscovery(
1929
1946
  payload.path,
1930
- payload.candidate_size
1947
+ threshold
1931
1948
  )
1949
+ }
1932
1950
  case "cancel_find_folders":
1933
1951
  return this.cancelFolderDiscovery()
1934
1952
  case "clear_find_folders":
@@ -3115,6 +3133,33 @@ class Vault {
3115
3133
  return scoped
3116
3134
  }
3117
3135
 
3136
+ async candidateSizeSetting(scopeId = null) {
3137
+ const setting = await this.registry.scanSetting(scopeId)
3138
+ const minimum = setting && Number(setting.candidate_min_bytes)
3139
+ if (CANDIDATE_SIZE_OPTIONS.includes(minimum)) return minimum
3140
+ return SIZE_THRESHOLD
3141
+ }
3142
+
3143
+ async setCandidateSizeSetting(scopeId = null, candidateMinBytes) {
3144
+ if (!Number.isSafeInteger(candidateMinBytes) ||
3145
+ !CANDIDATE_SIZE_OPTIONS.includes(candidateMinBytes)) {
3146
+ return { error: "Choose a valid minimum file size." }
3147
+ }
3148
+ const setting = await this.registry.setScanSetting(
3149
+ scopeId,
3150
+ candidateMinBytes
3151
+ )
3152
+ return {
3153
+ scope_id: scopeId || null,
3154
+ candidate_min_bytes: setting.candidate_min_bytes,
3155
+ updated_at: setting.updated_at
3156
+ }
3157
+ }
3158
+
3159
+ candidateSizeForApp(app) {
3160
+ return this.candidateSizeSetting(sourceId("app", app))
3161
+ }
3162
+
3118
3163
  sourceCountMaps(summaryRows) {
3119
3164
  const counts = {
3120
3165
  all: {},
@@ -3391,13 +3436,10 @@ class Vault {
3391
3436
  const lastScan = await this.scanForScope(scopeId)
3392
3437
  const globalScan = scopeId ? await this.scanForScope(null) : lastScan
3393
3438
  const globalScanReady = this.globalScanIsReady(globalScan)
3394
- const publishedCandidateMinimum = globalScan
3395
- ? Number(globalScan.candidate_min_bytes)
3396
- : NaN
3397
- const globalCandidateMinimum = Number.isFinite(
3398
- publishedCandidateMinimum) && publishedCandidateMinimum >= 0
3399
- ? publishedCandidateMinimum
3400
- : SIZE_THRESHOLD
3439
+ const candidateMinimum = await this.candidateSizeSetting(scopeId)
3440
+ const globalCandidateMinimum = scopeId
3441
+ ? await this.candidateSizeSetting(null)
3442
+ : candidateMinimum
3401
3443
  const before = lastScan && Number.isFinite(lastScan.bytes_total)
3402
3444
  ? lastScan.bytes_total
3403
3445
  : 0
@@ -3435,6 +3477,7 @@ class Vault {
3435
3477
  const result = {
3436
3478
  enabled: true,
3437
3479
  global_scan_ready: globalScanReady,
3480
+ candidate_min_bytes: candidateMinimum,
3438
3481
  global_candidate_min_bytes: globalCandidateMinimum,
3439
3482
  mode: this.mode,
3440
3483
  scan: this.scanStatus(),
@@ -172,6 +172,8 @@ for (const method of [
172
172
  "removeAutomaticAppScanApp",
173
173
  "setAutomaticAppScanState",
174
174
  "scanFor",
175
+ "scanSetting",
176
+ "setScanSetting",
175
177
  "removeScan",
176
178
  "beginScan",
177
179
  "abortScan",
@@ -306,6 +306,13 @@ class RegistryCore {
306
306
  FROM automatic_app_scans
307
307
  WHERE state = 'paused';
308
308
 
309
+ CREATE TABLE IF NOT EXISTS minimum_size_settings (
310
+ scope_id TEXT PRIMARY KEY,
311
+ candidate_min_bytes INTEGER NOT NULL CHECK (
312
+ candidate_min_bytes >= 0
313
+ ),
314
+ updated_at INTEGER NOT NULL
315
+ );
309
316
  `)
310
317
  const automaticScanColumns = new Set(this.database.prepare(
311
318
  "PRAGMA table_info(automatic_app_scans)"
@@ -2670,6 +2677,38 @@ class RegistryCore {
2670
2677
  })
2671
2678
  }
2672
2679
 
2680
+ scanSetting(scopeId = null) {
2681
+ const row = this.database.prepare(`
2682
+ SELECT candidate_min_bytes, updated_at
2683
+ FROM minimum_size_settings
2684
+ WHERE scope_id = ?
2685
+ `).get(scopeId || "")
2686
+ return row ? {
2687
+ candidate_min_bytes: Number(row.candidate_min_bytes),
2688
+ updated_at: Number(row.updated_at) || 0
2689
+ } : null
2690
+ }
2691
+
2692
+ setScanSetting(scopeId = null, candidateMinBytes) {
2693
+ const minimum = candidateMinBytes
2694
+ if (!Number.isSafeInteger(minimum) || minimum < 0) {
2695
+ throw new Error("Invalid minimum file size setting.")
2696
+ }
2697
+ const updatedAt = Date.now()
2698
+ this.database.prepare(`
2699
+ INSERT INTO minimum_size_settings(
2700
+ scope_id, candidate_min_bytes, updated_at
2701
+ ) VALUES (?, ?, ?)
2702
+ ON CONFLICT(scope_id) DO UPDATE SET
2703
+ candidate_min_bytes = excluded.candidate_min_bytes,
2704
+ updated_at = excluded.updated_at
2705
+ `).run(scopeId || "", minimum, updatedAt)
2706
+ return {
2707
+ candidate_min_bytes: minimum,
2708
+ updated_at: updatedAt
2709
+ }
2710
+ }
2711
+
2673
2712
  automaticAppScanStates() {
2674
2713
  return this.database.prepare(`
2675
2714
  SELECT app, state, signature, updated_at
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.78",
3
+ "version": "8.0.79",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -276,17 +276,24 @@
276
276
  return;
277
277
  }
278
278
  const rect = anchor.getBoundingClientRect();
279
- const scrollX = window.scrollX || window.pageXOffset || 0;
280
- const scrollY = window.scrollY || window.pageYOffset || 0;
281
- const top = rect.bottom + scrollY + 6;
282
- let left = rect.left + scrollX;
279
+ const viewportPadding = 12;
280
+ const menuGap = 6;
283
281
  const menuWidth = menu.offsetWidth || 0;
284
- const viewportRight = scrollX + window.innerWidth;
285
- if (left + menuWidth > viewportRight - 12) {
286
- left = Math.max(scrollX + 12, viewportRight - menuWidth - 12);
287
- }
288
- if (left < scrollX + 12) {
289
- left = scrollX + 12;
282
+ const menuHeight = menu.offsetHeight || 0;
283
+ let left = rect.left;
284
+ let top = rect.bottom + menuGap;
285
+ if (left + menuWidth > window.innerWidth - viewportPadding) {
286
+ left = Math.max(viewportPadding, window.innerWidth - menuWidth - viewportPadding);
287
+ }
288
+ if (left < viewportPadding) {
289
+ left = viewportPadding;
290
+ }
291
+ const topAbove = rect.top - menuHeight - menuGap;
292
+ if (top + menuHeight > window.innerHeight - viewportPadding && topAbove >= viewportPadding) {
293
+ top = topAbove;
294
+ } else {
295
+ top = Math.min(top, Math.max(viewportPadding, window.innerHeight - menuHeight - viewportPadding));
296
+ top = Math.max(viewportPadding, top);
290
297
  }
291
298
  menu.style.top = `${Math.round(top)}px`;
292
299
  menu.style.left = `${Math.round(left)}px`;
@@ -857,10 +864,13 @@
857
864
  color: var(--pinokio-focus-color, #4c9afe);
858
865
  }
859
866
  .pinokio-notify-popover {
860
- position: absolute;
867
+ position: fixed;
861
868
  z-index: 2147482000;
862
869
  min-width: 220px;
863
870
  max-width: 280px;
871
+ max-height: calc(100dvh - 24px);
872
+ overflow-y: auto;
873
+ overscroll-behavior: contain;
864
874
  color: #f8fafc;
865
875
  background: rgba(15, 23, 42, 0.97);
866
876
  border-radius: 10px;
@@ -980,6 +990,133 @@ const syncToggleAppearance = (toggle, enabled) => {
980
990
  });
981
991
  };
982
992
 
993
+ const updateNotificationPreferenceForFrame = (frameName, enabled) => {
994
+ const state = getOrCreateState(frameName);
995
+ if (!state) {
996
+ return null;
997
+ }
998
+ const next = Boolean(enabled);
999
+ state.notifyEnabled = next;
1000
+ setPreference(frameName, next);
1001
+ syncInlineToggleStateForFrame(frameName);
1002
+ return next;
1003
+ };
1004
+
1005
+ const populateInlineSoundSelect = (select, options) => {
1006
+ if (!(select instanceof HTMLSelectElement)) {
1007
+ return;
1008
+ }
1009
+ const effectiveOptions = Array.isArray(options) && options.length > 0
1010
+ ? options
1011
+ : baseSoundOptions();
1012
+ const selectedChoice = globalSoundPreference?.choice || SOUND_DEFAULT_CHOICE;
1013
+ select.replaceChildren();
1014
+ effectiveOptions.forEach((option) => {
1015
+ if (!option || !option.value || !option.label) {
1016
+ return;
1017
+ }
1018
+ const node = document.createElement('option');
1019
+ node.value = option.value;
1020
+ node.textContent = option.label;
1021
+ node.selected = option.value === selectedChoice;
1022
+ select.appendChild(node);
1023
+ });
1024
+ if (Array.from(select.options).some((option) => option.value === selectedChoice)) {
1025
+ select.value = selectedChoice;
1026
+ } else {
1027
+ select.value = SOUND_DEFAULT_CHOICE;
1028
+ }
1029
+ };
1030
+
1031
+ const mountNotificationSettingsForLink = (link, container, callbacks = {}) => {
1032
+ if (!(container instanceof HTMLElement)) {
1033
+ return false;
1034
+ }
1035
+ const context = getNotificationMenuStateForLink(link);
1036
+ if (!context.available || !context.frameName) {
1037
+ return false;
1038
+ }
1039
+ const state = getOrCreateState(context.frameName);
1040
+ if (!state) {
1041
+ return false;
1042
+ }
1043
+
1044
+ container.replaceChildren();
1045
+
1046
+ const toggleRow = document.createElement('div');
1047
+ toggleRow.className = 'tab-link-notification-setting-row';
1048
+ const toggleLabel = document.createElement('span');
1049
+ toggleLabel.className = 'tab-link-notification-setting-label';
1050
+ toggleLabel.textContent = 'Notifications for this tab';
1051
+ const toggleControl = document.createElement('div');
1052
+ toggleControl.className = 'tab-link-notification-toggle-control';
1053
+ const toggleStatus = document.createElement('span');
1054
+ toggleStatus.className = 'tab-link-notification-toggle-status';
1055
+ const toggle = document.createElement('button');
1056
+ toggle.type = 'button';
1057
+ toggle.className = 'tab-link-notification-switch';
1058
+ toggle.setAttribute('role', 'switch');
1059
+ toggle.setAttribute('aria-label', 'Notifications for this tab');
1060
+ const toggleThumb = document.createElement('span');
1061
+ toggleThumb.className = 'tab-link-notification-switch-thumb';
1062
+ toggle.setAttribute('aria-describedby', `${container.id || 'tab-link-notification-settings'}-status`);
1063
+ toggleStatus.id = `${container.id || 'tab-link-notification-settings'}-status`;
1064
+ toggle.appendChild(toggleThumb);
1065
+ toggleControl.append(toggleStatus, toggle);
1066
+ toggleRow.append(toggleLabel, toggleControl);
1067
+
1068
+ const soundRow = document.createElement('label');
1069
+ soundRow.className = 'tab-link-notification-setting-row';
1070
+ const soundLabel = document.createElement('span');
1071
+ soundLabel.className = 'tab-link-notification-setting-label';
1072
+ soundLabel.textContent = 'Sound';
1073
+ const soundSelect = document.createElement('select');
1074
+ soundSelect.className = 'tab-link-notification-sound-select';
1075
+ soundSelect.setAttribute('aria-label', 'Notification sound');
1076
+ soundRow.append(soundLabel, soundSelect);
1077
+
1078
+ const syncToggle = (enabled) => {
1079
+ toggle.setAttribute('aria-checked', enabled ? 'true' : 'false');
1080
+ toggle.dataset.enabled = enabled ? 'true' : 'false';
1081
+ toggleStatus.textContent = enabled ? 'On' : 'Off';
1082
+ };
1083
+
1084
+ syncToggle(Boolean(state.notifyEnabled));
1085
+ populateInlineSoundSelect(soundSelect, soundOptionsCache || baseSoundOptions());
1086
+ container.append(toggleRow, soundRow);
1087
+
1088
+ toggle.addEventListener('click', () => {
1089
+ const current = getOrCreateState(context.frameName);
1090
+ if (!current) {
1091
+ return;
1092
+ }
1093
+ const enabled = updateNotificationPreferenceForFrame(context.frameName, !current.notifyEnabled);
1094
+ if (enabled === null) {
1095
+ return;
1096
+ }
1097
+ syncToggle(enabled);
1098
+ if (typeof callbacks.onEnabledChange === 'function') {
1099
+ callbacks.onEnabledChange(enabled);
1100
+ }
1101
+ });
1102
+
1103
+ soundSelect.addEventListener('change', () => {
1104
+ applySoundSelection(soundSelect.value);
1105
+ if (typeof callbacks.onSoundChange === 'function') {
1106
+ callbacks.onSoundChange(globalSoundPreference.choice);
1107
+ }
1108
+ });
1109
+
1110
+ loadSoundOptions().then((options) => {
1111
+ if (!container.isConnected || !container.contains(soundSelect)) {
1112
+ return;
1113
+ }
1114
+ populateInlineSoundSelect(soundSelect, options);
1115
+ }).catch(() => {});
1116
+
1117
+ return true;
1118
+ };
1119
+
983
1120
  const getNotificationMenuStateForLink = (link) => {
984
1121
  if (!(link instanceof HTMLElement)) {
985
1122
  return { available: false, enabled: true, frameName: null };
@@ -1498,5 +1635,8 @@ const ensureTabAccessories = aggregateDebounce(() => {
1498
1635
  openMenuForLink(link, anchor) {
1499
1636
  return openNotificationMenuForLink(link, anchor);
1500
1637
  },
1638
+ mountSettingsForLink(link, container, callbacks) {
1639
+ return mountNotificationSettingsForLink(link, container, callbacks);
1640
+ },
1501
1641
  };
1502
1642
  })();
@@ -220,6 +220,113 @@ body.dark .tab-link-popover .tab-link-popover-item:hover,
220
220
  body.dark .tab-link-popover .tab-link-popover-item:focus-visible {
221
221
  background: rgba(148, 163, 184, 0.12);
222
222
  }
223
+ .tab-link-popover .tab-link-popover-item[aria-expanded="true"] {
224
+ background: rgba(15, 23, 42, 0.04);
225
+ }
226
+ body.dark .tab-link-popover .tab-link-popover-item[aria-expanded="true"] {
227
+ background: rgba(148, 163, 184, 0.08);
228
+ }
229
+ .tab-link-notification-settings {
230
+ margin: 0 14px 10px 40px;
231
+ border: 1px solid rgba(15, 23, 42, 0.1);
232
+ border-radius: 7px;
233
+ background: rgba(15, 23, 42, 0.025);
234
+ overflow: hidden;
235
+ }
236
+ body.dark .tab-link-notification-settings {
237
+ border-color: rgba(148, 163, 184, 0.18);
238
+ background: rgba(148, 163, 184, 0.045);
239
+ }
240
+ .tab-link-notification-setting-row {
241
+ min-height: 44px;
242
+ padding: 7px 10px;
243
+ display: grid;
244
+ grid-template-columns: minmax(0, 1fr) auto;
245
+ align-items: center;
246
+ gap: 12px;
247
+ box-sizing: border-box;
248
+ color: inherit;
249
+ }
250
+ .tab-link-notification-setting-row + .tab-link-notification-setting-row {
251
+ border-top: 1px solid rgba(15, 23, 42, 0.08);
252
+ }
253
+ body.dark .tab-link-notification-setting-row + .tab-link-notification-setting-row {
254
+ border-top-color: rgba(148, 163, 184, 0.14);
255
+ }
256
+ .tab-link-notification-setting-label {
257
+ min-width: 0;
258
+ font-size: 12px;
259
+ font-weight: 500;
260
+ color: rgba(15, 23, 42, 0.76);
261
+ }
262
+ body.dark .tab-link-notification-setting-label {
263
+ color: rgba(226, 232, 240, 0.86);
264
+ }
265
+ .tab-link-notification-toggle-control {
266
+ display: inline-flex;
267
+ align-items: center;
268
+ gap: 7px;
269
+ }
270
+ .tab-link-notification-toggle-status {
271
+ min-width: 18px;
272
+ font-size: 11px;
273
+ text-align: right;
274
+ color: rgba(15, 23, 42, 0.56);
275
+ }
276
+ body.dark .tab-link-notification-toggle-status {
277
+ color: rgba(226, 232, 240, 0.62);
278
+ }
279
+ .tab-link-notification-switch {
280
+ position: relative;
281
+ width: 36px;
282
+ height: 26px;
283
+ padding: 4px;
284
+ appearance: none;
285
+ border: 0;
286
+ border-radius: 999px;
287
+ background: rgba(100, 116, 139, 0.42);
288
+ cursor: pointer;
289
+ transition: background-color 140ms ease;
290
+ }
291
+ .tab-link-notification-switch[data-enabled="true"] {
292
+ background: var(--pinokio-focus-color, #2563eb);
293
+ }
294
+ .tab-link-notification-switch-thumb {
295
+ display: block;
296
+ width: 18px;
297
+ height: 18px;
298
+ border-radius: 50%;
299
+ background: #f8fafc;
300
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.24);
301
+ transform: translateX(0);
302
+ transition: transform 140ms ease;
303
+ }
304
+ .tab-link-notification-switch[data-enabled="true"] .tab-link-notification-switch-thumb {
305
+ transform: translateX(10px);
306
+ }
307
+ .tab-link-notification-switch:focus-visible,
308
+ .tab-link-notification-sound-select:focus-visible {
309
+ outline: 2px solid var(--pinokio-focus-color, #2563eb);
310
+ outline-offset: 2px;
311
+ }
312
+ .tab-link-notification-sound-select {
313
+ width: min(160px, 44vw);
314
+ min-height: 32px;
315
+ padding: 5px 28px 5px 9px;
316
+ border: 1px solid rgba(15, 23, 42, 0.14);
317
+ border-radius: 6px;
318
+ background: var(--pinokio-sidebar-tabbar-bg, #ffffff);
319
+ color: rgba(15, 23, 42, 0.82);
320
+ font: inherit;
321
+ font-size: 12px;
322
+ cursor: pointer;
323
+ }
324
+ body.dark .tab-link-notification-sound-select {
325
+ border-color: rgba(148, 163, 184, 0.22);
326
+ background: rgba(15, 23, 42, 0.42);
327
+ color: rgba(241, 245, 249, 0.9);
328
+ color-scheme: dark;
329
+ }
223
330
  .tab-link-popover .tab-link-popover-item .label {
224
331
  font-size: 11px;
225
332
  font-weight: 600;
@@ -344,6 +451,27 @@ body.dark .tab-link-popover .tab-link-popover-footer:focus-visible {
344
451
  padding-right: 16px;
345
452
  padding-left: 16px;
346
453
  }
454
+ .tab-link-notification-settings {
455
+ margin-right: 16px;
456
+ margin-left: 42px;
457
+ }
458
+ .tab-link-notification-setting-row {
459
+ padding-right: 8px;
460
+ padding-left: 8px;
461
+ }
462
+ }
463
+ @media (pointer: coarse) {
464
+ .tab-link-notification-switch {
465
+ width: 44px;
466
+ height: 30px;
467
+ padding: 4px;
468
+ }
469
+ .tab-link-notification-switch[data-enabled="true"] .tab-link-notification-switch-thumb {
470
+ transform: translateX(14px);
471
+ }
472
+ .tab-link-notification-sound-select {
473
+ min-height: 44px;
474
+ }
347
475
  }
348
476
  .appcanvas > aside .menu-container .tab-link-popover-host {
349
477
  display: flex;
@@ -266,10 +266,39 @@
266
266
  if (action === "notifications") {
267
267
  const activeLink = tabLinkActiveLink
268
268
  const notifier = typeof window !== "undefined" ? window.PinokioIdleNotifier : null
269
- const anchor = activeLink ? (activeLink.querySelector(`.${TAB_LINK_TRIGGER_CLASS}`) || activeLink) : null
270
- hideTabLinkPopover({ immediate: true })
271
- if (notifier && typeof notifier.openMenuForLink === "function") {
272
- notifier.openMenuForLink(activeLink, anchor)
269
+ const existingPanel = item.nextElementSibling
270
+ if (existingPanel && existingPanel.classList.contains("tab-link-notification-settings")) {
271
+ existingPanel.remove()
272
+ item.setAttribute("aria-expanded", "false")
273
+ item.removeAttribute("aria-controls")
274
+ return
275
+ }
276
+ if (notifier && typeof notifier.mountSettingsForLink === "function") {
277
+ const panel = document.createElement("div")
278
+ panel.className = "tab-link-notification-settings"
279
+ panel.id = `${TAB_LINK_POPOVER_ID}-notification-settings`
280
+ panel.setAttribute("role", "group")
281
+ panel.setAttribute("aria-label", "Desktop notification settings")
282
+ item.insertAdjacentElement("afterend", panel)
283
+ const value = item.querySelector(".value")
284
+ const icon = item.querySelector(".tab-link-popover-action-icon i")
285
+ const mounted = notifier.mountSettingsForLink(activeLink, panel, {
286
+ onEnabledChange(enabled) {
287
+ if (value) {
288
+ value.textContent = enabled ? "Enabled for this tab" : "Disabled for this tab"
289
+ }
290
+ if (icon) {
291
+ icon.classList.toggle("fa-bell", enabled)
292
+ icon.classList.toggle("fa-bell-slash", !enabled)
293
+ }
294
+ }
295
+ })
296
+ if (mounted) {
297
+ item.setAttribute("aria-controls", panel.id)
298
+ item.setAttribute("aria-expanded", "true")
299
+ } else {
300
+ panel.remove()
301
+ }
273
302
  }
274
303
  return
275
304
  }
@@ -1898,6 +1927,9 @@
1898
1927
  if (actionItem.action) {
1899
1928
  item.setAttribute("data-action", actionItem.action)
1900
1929
  }
1930
+ if (actionItem.action === "notifications") {
1931
+ item.setAttribute("aria-expanded", "false")
1932
+ }
1901
1933
  if (actionItem.url) {
1902
1934
  item.setAttribute("data-url", actionItem.url)
1903
1935
  }
@@ -402,6 +402,7 @@ const PAGE_SIZE = 500
402
402
  const state = {
403
403
  data: null,
404
404
  candidateSize: defaultCandidateSize,
405
+ persistedCandidateSize: defaultCandidateSize,
405
406
  candidateSizeInitialized: false,
406
407
  view: "all",
407
408
  sourceId: SCOPE_ID,
@@ -546,6 +547,52 @@ const post = async (payload) => {
546
547
  if (!result) throw new Error(COPY.action_request_failed.replace("{status}", response.status))
547
548
  return result
548
549
  }
550
+ let candidateSizeSaveTail = Promise.resolve(true)
551
+ let candidateSizeSaveGeneration = 0
552
+ const saveCandidateSize = (size) => {
553
+ if (!candidateSizeOptions.includes(size)) return candidateSizeSaveTail
554
+ const generation = ++candidateSizeSaveGeneration
555
+ state.candidateSize = size
556
+ state.candidateSizeInitialized = true
557
+ renderCandidateSizeControl()
558
+ const save = candidateSizeSaveTail.then(async () => {
559
+ const result = await post({
560
+ action: "set_candidate_size",
561
+ scope_id: SCOPE_ID,
562
+ candidate_size: size
563
+ })
564
+ if (result.error) throw new Error(result.error)
565
+ state.persistedCandidateSize = size
566
+ return true
567
+ })
568
+ candidateSizeSaveTail = save.catch(async (error) => {
569
+ const isCurrent = () => generation === candidateSizeSaveGeneration
570
+ if (isCurrent() && candidateSize() === size) {
571
+ state.candidateSizeInitialized = false
572
+ const refreshed = await refresh(true)
573
+ if (!refreshed && isCurrent() && !state.candidateSizeInitialized) {
574
+ state.candidateSize = state.persistedCandidateSize
575
+ state.candidateSizeInitialized = true
576
+ renderCandidateSizeControl()
577
+ }
578
+ }
579
+ if (isCurrent()) {
580
+ state.feedback = {
581
+ error: true,
582
+ message: error && error.message ? error.message : String(error)
583
+ }
584
+ renderFeedback()
585
+ }
586
+ return false
587
+ })
588
+ const pending = candidateSizeSaveTail
589
+ pending.finally(() => {
590
+ if (candidateSizeSaveTail === pending) {
591
+ candidateSizeSaveTail = Promise.resolve(true)
592
+ }
593
+ })
594
+ return candidateSizeSaveTail
595
+ }
549
596
  const openGlobalWorkspace = () => {
550
597
  window.parent.location.assign("/vault")
551
598
  }
@@ -2846,10 +2893,13 @@ const settleFolderDiscoveryStart = () => {
2846
2893
  }
2847
2894
  const applyFullData = (data) => {
2848
2895
  if (!state.candidateSizeInitialized) {
2849
- const published = Number(data && data.global_candidate_min_bytes)
2850
- state.candidateSize = candidateSizeOptions.includes(published)
2851
- ? published
2896
+ const saved = Number(data && data.candidate_min_bytes)
2897
+ const fallback = Number(data && data.global_candidate_min_bytes)
2898
+ const selected = candidateSizeOptions.includes(saved) ? saved : fallback
2899
+ state.candidateSize = candidateSizeOptions.includes(selected)
2900
+ ? selected
2852
2901
  : defaultCandidateSize
2902
+ state.persistedCandidateSize = state.candidateSize
2853
2903
  state.candidateSizeInitialized = true
2854
2904
  }
2855
2905
  const scanning = scanActive(data.scan)
@@ -3228,10 +3278,16 @@ const startFolderDiscovery = async (folderPath) => {
3228
3278
  renderFolderDiscovery()
3229
3279
  try {
3230
3280
  resetFolderDiscoveryChoices()
3281
+ if (!await candidateSizeSaveTail) {
3282
+ state.folderDiscoveryStarting = false
3283
+ state.folderDiscoveryChoosingRoot = true
3284
+ renderFolderDiscovery()
3285
+ focusFolderDiscoveryDialog()
3286
+ return
3287
+ }
3231
3288
  const result = await post({
3232
3289
  action: "find_folders",
3233
- path: folderPath,
3234
- candidate_size: candidateSize()
3290
+ path: folderPath
3235
3291
  })
3236
3292
  if (result.error) throw new Error(result.error)
3237
3293
  if (!result.started && !result.already_running) {
@@ -3354,10 +3410,8 @@ document.addEventListener("click", async (event) => {
3354
3410
  if (target.hasAttribute("data-candidate-size")) {
3355
3411
  const size = Number(target.dataset.candidateSize)
3356
3412
  if (candidateSizeOptions.includes(size)) {
3357
- state.candidateSize = size
3358
- state.candidateSizeInitialized = true
3359
3413
  closeScanSizeMenu()
3360
- renderCandidateSizeControl()
3414
+ await saveCandidateSize(size)
3361
3415
  }
3362
3416
  return
3363
3417
  }
@@ -3768,10 +3822,13 @@ document.addEventListener("click", async (event) => {
3768
3822
  state.scanPreviewOpen = false
3769
3823
  state.feedback = null
3770
3824
  try {
3825
+ if (!await candidateSizeSaveTail) {
3826
+ state.scanRequested = false
3827
+ return
3828
+ }
3771
3829
  const result = await post({
3772
3830
  action: "scan",
3773
- scope_id: SCOPE_ID,
3774
- candidate_size: candidateSize()
3831
+ scope_id: SCOPE_ID
3775
3832
  })
3776
3833
  if (result.error) throw new Error(result.error)
3777
3834
  await refresh()
@@ -4227,11 +4284,9 @@ document.addEventListener("change", async (event) => {
4227
4284
  if (["vault-candidate-size", "vault-find-candidate-size"]
4228
4285
  .includes(event.target.id)) {
4229
4286
  const size = Number(event.target.value)
4230
- state.candidateSize = candidateSizeOptions.includes(size)
4287
+ await saveCandidateSize(candidateSizeOptions.includes(size)
4231
4288
  ? size
4232
- : defaultCandidateSize
4233
- state.candidateSizeInitialized = true
4234
- renderCandidateSizeControl()
4289
+ : defaultCandidateSize)
4235
4290
  return
4236
4291
  }
4237
4292
  if (event.target.id !== "vault-status-filter") return
@@ -0,0 +1,135 @@
1
+ const assert = require("node:assert/strict")
2
+ const fs = require("node:fs/promises")
3
+ const path = require("node:path")
4
+ const test = require("node:test")
5
+ const { JSDOM, VirtualConsole } = require("jsdom")
6
+
7
+ const root = path.resolve(__dirname, "..")
8
+ const notifierPath = path.resolve(root, "server/public/tab-idle-notifier.js")
9
+ const popoverPath = path.resolve(root, "server/public/tab-link-popover.js")
10
+ const popoverCssPath = path.resolve(root, "server/public/tab-link-popover.css")
11
+
12
+ const settle = () => new Promise((resolve) => setImmediate(resolve))
13
+
14
+ test("desktop notification settings mount inline and persist both scopes", async () => {
15
+ const script = await fs.readFile(notifierPath, "utf8")
16
+ const virtualConsole = new VirtualConsole()
17
+ const dom = new JSDOM(`<!doctype html><html><head></head><body>
18
+ <div class="appcanvas vertical">
19
+ <aside>
20
+ <div class="menu-container">
21
+ <a class="frame-link" href="/run/test" target="test-shell" data-can-notify="true">
22
+ <span class="tab"><span class="tab-main">Test</span></span>
23
+ </a>
24
+ </div>
25
+ </aside>
26
+ </div>
27
+ <div id="settings"></div>
28
+ </body></html>`, {
29
+ url: "http://127.0.0.1:42000/run/test",
30
+ runScripts: "outside-only",
31
+ pretendToBeVisual: true,
32
+ virtualConsole,
33
+ beforeParse(window) {
34
+ window.fetch = async () => ({
35
+ ok: true,
36
+ async json() {
37
+ return {
38
+ sounds: [{
39
+ url: "/sound/bell.mp3",
40
+ label: "Bell",
41
+ filename: "bell.mp3"
42
+ }]
43
+ }
44
+ }
45
+ })
46
+ window.Audio = class {
47
+ play() {
48
+ return Promise.resolve()
49
+ }
50
+ }
51
+ }
52
+ })
53
+
54
+ try {
55
+ dom.window.eval(script)
56
+ const link = dom.window.document.querySelector(".frame-link")
57
+ const settings = dom.window.document.getElementById("settings")
58
+ let enabledChange = null
59
+ let soundChange = null
60
+
61
+ const mounted = dom.window.PinokioIdleNotifier.mountSettingsForLink(link, settings, {
62
+ onEnabledChange(enabled) {
63
+ enabledChange = enabled
64
+ },
65
+ onSoundChange(choice) {
66
+ soundChange = choice
67
+ }
68
+ })
69
+
70
+ assert.equal(mounted, true)
71
+ assert.equal(settings.textContent.includes("Notifications for this tab"), true)
72
+ assert.equal(settings.textContent.includes("Sound"), true)
73
+
74
+ const toggle = settings.querySelector('[role="switch"]')
75
+ const status = settings.querySelector(".tab-link-notification-toggle-status")
76
+ const select = settings.querySelector("select")
77
+ assert.equal(toggle.getAttribute("aria-checked"), "true")
78
+ assert.equal(status.textContent, "On")
79
+ assert.equal(select.value, "__default__")
80
+
81
+ toggle.click()
82
+ assert.equal(toggle.getAttribute("aria-checked"), "false")
83
+ assert.equal(status.textContent, "Off")
84
+ assert.equal(enabledChange, false)
85
+ assert.deepEqual(JSON.parse(dom.window.localStorage.getItem("pinokio:idle-prefs")), {
86
+ "test-shell": false
87
+ })
88
+
89
+ toggle.click()
90
+ assert.equal(toggle.getAttribute("aria-checked"), "true")
91
+ assert.equal(status.textContent, "On")
92
+ assert.equal(enabledChange, true)
93
+ assert.equal(dom.window.localStorage.getItem("pinokio:idle-prefs"), null)
94
+
95
+ await settle()
96
+ assert.equal(Array.from(select.options).some((option) => option.textContent === "Bell"), true)
97
+ select.value = "/sound/bell.mp3"
98
+ select.dispatchEvent(new dom.window.Event("change", { bubbles: true }))
99
+ assert.equal(soundChange, "/sound/bell.mp3")
100
+ assert.deepEqual(JSON.parse(dom.window.localStorage.getItem("pinokio:idle-sound")), {
101
+ choice: "/sound/bell.mp3"
102
+ })
103
+ } finally {
104
+ dom.window.close()
105
+ }
106
+ })
107
+
108
+ test("tab actions expands notification settings instead of reopening at the sidebar", async () => {
109
+ const [popover, css] = await Promise.all([
110
+ fs.readFile(popoverPath, "utf8"),
111
+ fs.readFile(popoverCssPath, "utf8")
112
+ ])
113
+ const branchStart = popover.indexOf('if (action === "notifications")')
114
+ const branchEnd = popover.indexOf('const url = item.getAttribute("data-url")', branchStart)
115
+ assert.notEqual(branchStart, -1)
116
+ assert.notEqual(branchEnd, -1)
117
+ const branch = popover.slice(branchStart, branchEnd)
118
+
119
+ assert.match(branch, /mountSettingsForLink\(activeLink, panel/)
120
+ assert.match(branch, /tab-link-notification-settings/)
121
+ assert.match(branch, /panel\.setAttribute\("role", "group"\)/)
122
+ assert.doesNotMatch(branch, /hideTabLinkPopover/)
123
+ assert.doesNotMatch(branch, /openMenuForLink/)
124
+ assert.match(css, /\.tab-link-notification-settings\s*\{/)
125
+ assert.match(css, /\.tab-link-notification-switch:focus-visible/)
126
+ })
127
+
128
+ test("legacy notification popover stays inside the viewport", async () => {
129
+ const notifier = await fs.readFile(notifierPath, "utf8")
130
+
131
+ assert.match(notifier, /\.pinokio-notify-popover \{[\s\S]*position: fixed;/)
132
+ assert.match(notifier, /\.pinokio-notify-popover \{[\s\S]*max-height: calc\(100dvh - 24px\);/)
133
+ assert.match(notifier, /const topAbove = rect\.top - menuHeight - menuGap;/)
134
+ assert.match(notifier, /top \+ menuHeight > window\.innerHeight - viewportPadding/)
135
+ })
@@ -1247,6 +1247,7 @@ describe("automatic app checks", () => {
1247
1247
  await fs.promises.writeFile(second, "same-content")
1248
1248
  const vault = await makeVault(home)
1249
1249
  vault.lastScanCache.set("", { candidate_min_bytes: 1 })
1250
+ await vault.registry.setScanSetting(`app:${app}`, 0)
1250
1251
  const hashedPaths = []
1251
1252
  const originalHashFile = vault.hashFile.bind(vault)
1252
1253
  vault.hashFile = async (filePath, options) => {
@@ -29,13 +29,18 @@ const makeOutside = async () => {
29
29
  return directory
30
30
  }
31
31
 
32
- const makeVault = async () => {
32
+ const makeVault = async ({ candidateSize = CANDIDATE_SIZE_OPTIONS[0] } = {}) => {
33
33
  const home = await makeHome()
34
34
  const kernel = { homedir: home, platform: process.platform }
35
35
  const vault = new Vault(kernel)
36
36
  kernel.vault = vault
37
37
  await vault.init()
38
38
  vault.sizeThreshold = CANDIDATE_SIZE_OPTIONS[0]
39
+ if (candidateSize !== null) {
40
+ await vault.perform("set_candidate_size", {
41
+ candidate_size: candidateSize
42
+ })
43
+ }
39
44
  return { home, kernel, vault }
40
45
  }
41
46
 
@@ -175,15 +180,142 @@ describe("Save Space engine", () => {
175
180
  await close(vault)
176
181
  })
177
182
 
178
- test("the published global scan minimum is reused by automatic checks", async () => {
179
- const { vault } = await makeVault()
183
+ test("global and app minimum sizes persist and drive their own scans", async () => {
184
+ const { home, vault } = await makeVault()
185
+ const app = "minimum-size-app"
186
+ const appRoot = path.join(home, "api", app)
187
+ const appScope = `app:${app}`
188
+ const globalMinimum = CANDIDATE_SIZE_OPTIONS[2]
189
+ const appMinimum = CANDIDATE_SIZE_OPTIONS[1]
190
+ await fs.promises.mkdir(appRoot)
191
+ await vault.openWorkspace()
192
+
193
+ assert.equal((await vault.perform("set_candidate_size", {
194
+ candidate_size: globalMinimum
195
+ })).candidate_min_bytes, globalMinimum)
196
+ assert.equal((await vault.perform("set_candidate_size", {
197
+ scope_id: appScope,
198
+ candidate_size: appMinimum
199
+ })).candidate_min_bytes, appMinimum)
200
+ assert.equal((await vault.status()).candidate_min_bytes, globalMinimum)
201
+ assert.equal((await vault.status(appScope)).candidate_min_bytes, appMinimum)
202
+
203
+ const starts = []
204
+ vault.globalScanReady = async () => true
205
+ vault.startScan = (scopeId, threshold) => {
206
+ starts.push({ scope_id: scopeId, threshold })
207
+ return { started: true }
208
+ }
209
+ assert.deepEqual(await vault.perform("scan", {
210
+ candidate_size: CANDIDATE_SIZE_OPTIONS[0]
211
+ }), { started: true })
212
+ assert.deepEqual(await vault.perform("scan", {
213
+ scope_id: appScope,
214
+ candidate_size: CANDIDATE_SIZE_OPTIONS[0]
215
+ }), { started: true })
216
+ assert.deepEqual(starts, [
217
+ { scope_id: null, threshold: globalMinimum },
218
+ { scope_id: appScope, threshold: appMinimum }
219
+ ])
220
+ let folderThreshold = null
221
+ vault.startFolderDiscovery = (folderPath, threshold) => {
222
+ folderThreshold = threshold
223
+ return { started: true }
224
+ }
225
+ assert.deepEqual(await vault.perform("find_folders", {
226
+ path: home,
227
+ candidate_size: CANDIDATE_SIZE_OPTIONS[0]
228
+ }), { started: true })
229
+ assert.equal(folderThreshold, globalMinimum)
230
+ assert.equal((await vault.status()).candidate_min_bytes, globalMinimum)
231
+ assert.equal((await vault.status(appScope)).candidate_min_bytes, appMinimum)
232
+ assert.equal((await vault.automaticScans.candidatePolicy(app)).threshold,
233
+ appMinimum)
234
+
235
+ await close(vault)
236
+ const reopened = new Vault({ homedir: home, platform: process.platform })
237
+ await reopened.init()
238
+ assert.equal((await reopened.status()).candidate_min_bytes, globalMinimum)
239
+ assert.equal((await reopened.status(appScope)).candidate_min_bytes,
240
+ appMinimum)
241
+ await close(reopened)
242
+ })
243
+
244
+ test("reading a default minimum size does not persist it", async () => {
245
+ const { vault } = await makeVault({ candidateSize: null })
246
+
247
+ assert.equal(await vault.registry.scanSetting(), null)
248
+ assert.equal((await vault.status()).candidate_min_bytes, SIZE_THRESHOLD)
249
+ assert.equal(await vault.registry.scanSetting(), null)
250
+
251
+ await close(vault)
252
+ })
253
+
254
+ test("minimum size settings reject coerced values", async () => {
255
+ const { vault } = await makeVault({ candidateSize: null })
256
+
257
+ for (const candidateSize of [
258
+ null,
259
+ "",
260
+ String(CANDIDATE_SIZE_OPTIONS[1])
261
+ ]) {
262
+ assert.match((await vault.perform("set_candidate_size", {
263
+ candidate_size: candidateSize
264
+ })).error, /valid minimum file size/i)
265
+ }
266
+ assert.equal(await vault.registry.scanSetting(), null)
267
+
268
+ await close(vault)
269
+ })
270
+
271
+ test("minimum size settings reject malformed scopes", async () => {
272
+ const { vault } = await makeVault({ candidateSize: null })
273
+
274
+ const result = await vault.perform("set_candidate_size", {
275
+ scope_id: 123,
276
+ candidate_size: CANDIDATE_SIZE_OPTIONS[1]
277
+ })
278
+
279
+ assert.match(result.error, /valid scan scope/i)
280
+ assert.equal(await vault.registry.scanSetting(), null)
281
+
282
+ await close(vault)
283
+ })
284
+
285
+ test("scan history does not become a minimum size setting", async () => {
286
+ const home = await makeHome()
287
+ const vault = new Vault({ homedir: home, platform: process.platform })
288
+ await vault.init()
289
+ await vault.perform("set_candidate_size", {
290
+ candidate_size: CANDIDATE_SIZE_OPTIONS[2]
291
+ })
292
+ assert.equal((await vault.perform("scan")).started, true)
293
+ await waitForEngine(() => !vault.scanPromise &&
294
+ !vault.scanCompletionPromise)
295
+ await close(vault)
296
+
297
+ const database = new Database(path.join(home, "vault", "registry.sqlite3"))
298
+ database.exec("DROP TABLE minimum_size_settings")
299
+ database.close()
300
+
301
+ const reopened = new Vault({ homedir: home, platform: process.platform })
302
+ await reopened.init()
303
+ assert.equal((await reopened.status()).candidate_min_bytes,
304
+ SIZE_THRESHOLD)
305
+ assert.equal(await reopened.registry.scanSetting(), null)
306
+ await close(reopened)
307
+ })
308
+
309
+ test("the saved global minimum is reused by unscoped policy reads", async () => {
310
+ const { vault } = await makeVault({ candidateSize: null })
180
311
  const selected = CANDIDATE_SIZE_OPTIONS[2]
181
312
 
182
313
  assert.equal((await vault.status()).global_candidate_min_bytes,
183
314
  SIZE_THRESHOLD)
184
- assert.deepEqual(await vault.perform("scan", {
315
+ await vault.perform("set_candidate_size", {
185
316
  candidate_size: selected
186
- }), { started: true })
317
+ })
318
+ assert.deepEqual(await vault.perform("scan"), { started: true })
187
319
  await waitForEngine(() => !vault.scanPromise &&
188
320
  !vault.scanCompletionPromise)
189
321
 
@@ -994,6 +1126,9 @@ describe("Save Space engine", () => {
994
1126
  crypto.randomBytes(512))
995
1127
  vault.sizeThreshold = 0
996
1128
  await vault.sweeper.scan()
1129
+ await vault.perform("set_candidate_size", {
1130
+ candidate_size: selectedThreshold
1131
+ })
997
1132
 
998
1133
  const stageDiscoveryFiles = vault.registry.stageFolderDiscoveryFiles
999
1134
  .bind(vault.registry)
@@ -1039,18 +1174,22 @@ describe("Save Space engine", () => {
1039
1174
  await close(vault)
1040
1175
  })
1041
1176
 
1042
- test("Find folders requires an explicit supported threshold", async () => {
1177
+ test("Find folders ignores request thresholds and uses the saved setting", async () => {
1043
1178
  const { vault } = await makeVault()
1044
1179
  const outside = await makeOutside()
1180
+ const thresholds = []
1181
+ vault.startFolderDiscovery = (folderPath, threshold) => {
1182
+ thresholds.push(threshold)
1183
+ return { started: true }
1184
+ }
1045
1185
 
1046
- assert.match((await vault.perform("find_folders", {
1047
- path: outside
1048
- })).error, /valid minimum file size/i)
1049
- assert.match((await vault.perform("find_folders", {
1186
+ assert.deepEqual(await vault.perform("find_folders", {
1050
1187
  path: outside,
1051
1188
  candidate_size: 123
1052
- })).error, /valid minimum file size/i)
1053
- assert.equal(vault.folderDiscoveryPromise, null)
1189
+ }), { started: true })
1190
+ assert.deepEqual(thresholds, [CANDIDATE_SIZE_OPTIONS[0]])
1191
+ assert.equal((await vault.status()).candidate_min_bytes,
1192
+ CANDIDATE_SIZE_OPTIONS[0])
1054
1193
  await close(vault)
1055
1194
  })
1056
1195
 
@@ -414,7 +414,7 @@ const makePage = async (status, options = {}) => {
414
414
  const childManifest = parent
415
415
  ? folderDiscoveryChildren[parent]
416
416
  : null
417
- const response = parent
417
+ const response = await (parent
418
418
  ? typeof childManifest === "function"
419
419
  ? childManifest(childPage)
420
420
  : childManifest || {
@@ -425,7 +425,7 @@ const makePage = async (status, options = {}) => {
425
425
  page_size: 500,
426
426
  pages: 1
427
427
  }
428
- : typeof status === "function" ? status(url) : status
428
+ : typeof status === "function" ? status(url) : status)
429
429
  if (options.deferAutomaticStatus &&
430
430
  url === "/info/vault/automatic-scans") {
431
431
  await deferredAutomaticStatus
@@ -778,7 +778,7 @@ describe("Save Space interface", () => {
778
778
  request.action === "scan"))
779
779
  const scan = requests.find((request) => request.action === "scan")
780
780
  assert.equal(scan.scope_id, null)
781
- assert.equal(scan.candidate_size, 100 * candidateBase ** 2)
781
+ assert.equal("candidate_size" in scan, false)
782
782
  await settle()
783
783
  dom.window.close()
784
784
  })
@@ -866,12 +866,13 @@ describe("Save Space interface", () => {
866
866
  dom.window.close()
867
867
  })
868
868
 
869
- test("the scan minimum follows the latest global scan, not browser storage", async () => {
869
+ test("a scan relies on the saved scope setting, not browser storage", async () => {
870
870
  const candidateBase = process.platform === "win32" ? 1024 : 1000
871
- const published = 50 * candidateBase ** 2
871
+ const saved = 50 * candidateBase ** 2
872
872
  const stored = 10 * candidateBase ** 2
873
873
  const { dom, requests } = await makePage(fixture([], {
874
- global_candidate_min_bytes: published
874
+ candidate_min_bytes: saved,
875
+ global_candidate_min_bytes: 100 * candidateBase ** 2
875
876
  }), {
876
877
  storedCandidateSize: stored
877
878
  })
@@ -882,13 +883,39 @@ describe("Save Space interface", () => {
882
883
  document.getElementById("btn-scan").click()
883
884
  await waitFor(() => requests.some((request) =>
884
885
  request.action === "scan"))
885
- assert.equal(requests.find((request) =>
886
- request.action === "scan").candidate_size, published)
886
+ assert.equal("candidate_size" in requests.find((request) =>
887
+ request.action === "scan"), false)
887
888
 
888
889
  await settle()
889
890
  dom.window.close()
890
891
  })
891
892
 
893
+ test("an app displays and immediately saves its own minimum size", async () => {
894
+ const candidateBase = process.platform === "win32" ? 1024 : 1000
895
+ const appMinimum = candidateBase ** 2
896
+ const globalMinimum = 10 * candidateBase ** 2
897
+ const selected = 50 * candidateBase ** 2
898
+ const { dom, requests } = await makePage(fixture([], {
899
+ candidate_min_bytes: appMinimum,
900
+ global_candidate_min_bytes: globalMinimum
901
+ }), { appMode: true })
902
+ const document = dom.window.document
903
+ const selector = document.getElementById("vault-candidate-size")
904
+
905
+ assert.equal(selector.value, String(appMinimum))
906
+ selector.value = String(selected)
907
+ selector.dispatchEvent(new dom.window.Event("change", { bubbles: true }))
908
+ await waitFor(() => requests.some((request) =>
909
+ request.action === "set_candidate_size"))
910
+ const request = requests.find((entry) =>
911
+ entry.action === "set_candidate_size")
912
+ assert.equal(request.scope_id, "app:app")
913
+ assert.equal(request.candidate_size, selected)
914
+ assert.equal(selector.value, String(selected))
915
+
916
+ dom.window.close()
917
+ })
918
+
892
919
  test("a threshold chosen before initial status remains selected", async () => {
893
920
  const candidateBase = process.platform === "win32" ? 1024 : 1000
894
921
  const published = 50 * candidateBase ** 2
@@ -914,13 +941,87 @@ describe("Save Space interface", () => {
914
941
  document.getElementById("btn-scan").click()
915
942
  await waitFor(() => requests.some((request) =>
916
943
  request.action === "scan"))
917
- assert.equal(requests.find((request) =>
918
- request.action === "scan").candidate_size, selected)
944
+ const savedIndex = requests.findIndex((request) =>
945
+ request.action === "set_candidate_size")
946
+ const scanIndex = requests.findIndex((request) =>
947
+ request.action === "scan")
948
+ assert.ok(savedIndex >= 0 && savedIndex < scanIndex)
949
+ assert.equal("candidate_size" in requests[scanIndex], false)
919
950
 
920
951
  await settle()
921
952
  dom.window.close()
922
953
  })
923
954
 
955
+ test("a rejected minimum size save blocks a pending scan and restores the saved size", async () => {
956
+ const candidateBase = process.platform === "win32" ? 1024 : 1000
957
+ const saved = 50 * candidateBase ** 2
958
+ const selected = 10 * candidateBase ** 2
959
+ const { dom, requests } = await makePage(fixture([], {
960
+ candidate_min_bytes: saved,
961
+ global_candidate_min_bytes: saved
962
+ }), {
963
+ actionResults: {
964
+ set_candidate_size: { error: "Could not save minimum file size." }
965
+ }
966
+ })
967
+ const document = dom.window.document
968
+
969
+ document.querySelector(`[data-candidate-size="${selected}"]`).click()
970
+ document.getElementById("btn-scan").click()
971
+
972
+ await waitFor(() => /50 MB\+/.test(document.getElementById(
973
+ "vault-scan-size-label").textContent) &&
974
+ /could not save minimum file size/i.test(
975
+ document.getElementById("vault-feedback").textContent))
976
+ assert.equal(requests.some((request) => request.action === "scan"), false)
977
+
978
+ dom.window.close()
979
+ })
980
+
981
+ test("a stale minimum size failure does not replace a newer successful selection", async () => {
982
+ const candidateBase = process.platform === "win32" ? 1024 : 1000
983
+ const first = 10 * candidateBase ** 2
984
+ const second = 50 * candidateBase ** 2
985
+ const currentStatus = fixture([], {
986
+ candidate_min_bytes: 100 * candidateBase ** 2,
987
+ global_candidate_min_bytes: 100 * candidateBase ** 2
988
+ })
989
+ let statusCount = 0
990
+ let releaseRecovery
991
+ const recovery = new Promise((resolve) => { releaseRecovery = resolve })
992
+ let saveCount = 0
993
+ const actionResults = {}
994
+ Object.defineProperty(actionResults, "set_candidate_size", {
995
+ get() {
996
+ saveCount += 1
997
+ return saveCount === 1
998
+ ? { error: "Could not save minimum file size." }
999
+ : {}
1000
+ }
1001
+ })
1002
+ const { dom, requests } = await makePage(async () => {
1003
+ statusCount += 1
1004
+ if (statusCount === 2) await recovery
1005
+ return currentStatus
1006
+ }, { actionResults })
1007
+ const document = dom.window.document
1008
+
1009
+ document.querySelector(`[data-candidate-size="${first}"]`).click()
1010
+ await waitFor(() => statusCount === 2)
1011
+ document.querySelector(`[data-candidate-size="${second}"]`).click()
1012
+ releaseRecovery()
1013
+
1014
+ await waitFor(() => requests.filter((request) =>
1015
+ request.action === "set_candidate_size").length === 2)
1016
+ await settle()
1017
+ assert.match(document.getElementById("vault-scan-size-label").textContent,
1018
+ /50 MB\+/)
1019
+ assert.doesNotMatch(document.getElementById("vault-feedback").textContent,
1020
+ /could not save minimum file size/i)
1021
+
1022
+ dom.window.close()
1023
+ })
1024
+
924
1025
  test("a runtime write denial is reported as cannot deduplicate", async () => {
925
1026
  const duplicate = item({
926
1027
  status: "duplicate",
@@ -1090,15 +1191,49 @@ describe("Save Space interface", () => {
1090
1191
  request.action === "find_folders"))
1091
1192
  assert.equal(requests.find((entry) =>
1092
1193
  entry.action === "find_folders").path, "/Users/test")
1093
- assert.equal(requests.find((entry) =>
1094
- entry.action === "find_folders").candidate_size,
1095
- selectedThreshold)
1194
+ const savedIndex = requests.findIndex((request) =>
1195
+ request.action === "set_candidate_size")
1196
+ const findIndex = requests.findIndex((request) =>
1197
+ request.action === "find_folders")
1198
+ assert.ok(savedIndex >= 0 && savedIndex < findIndex)
1199
+ assert.equal("candidate_size" in requests[findIndex], false)
1096
1200
  assert.equal(pickerRequests.length, 0)
1097
1201
  await waitFor(() => document.getElementById(
1098
1202
  "vault-find-overlay").hidden)
1099
1203
  dom.window.close()
1100
1204
  })
1101
1205
 
1206
+ test("a rejected minimum size save blocks pending folder discovery", async () => {
1207
+ const candidateBase = process.platform === "win32" ? 1024 : 1000
1208
+ const saved = 100 * candidateBase ** 2
1209
+ const selected = 50 * candidateBase ** 2
1210
+ const { dom, requests } = await makePage(fixture([item()], {
1211
+ candidate_min_bytes: saved,
1212
+ global_candidate_min_bytes: saved
1213
+ }), {
1214
+ actionResults: {
1215
+ set_candidate_size: { error: "Could not save minimum file size." }
1216
+ }
1217
+ })
1218
+ const document = dom.window.document
1219
+
1220
+ document.getElementById("btn-find-folders").click()
1221
+ await waitFor(() => document.querySelector("[data-find-home-folder]"))
1222
+ const selector = document.getElementById("vault-find-candidate-size")
1223
+ selector.value = String(selected)
1224
+ selector.dispatchEvent(new dom.window.Event("change", { bubbles: true }))
1225
+ document.querySelector("[data-find-home-folder]").click()
1226
+
1227
+ await waitFor(() => document.getElementById(
1228
+ "vault-find-candidate-size").value === String(saved) &&
1229
+ /could not save minimum file size/i.test(
1230
+ document.getElementById("vault-feedback").textContent))
1231
+ assert.equal(requests.some((request) =>
1232
+ request.action === "find_folders"), false)
1233
+
1234
+ dom.window.close()
1235
+ })
1236
+
1102
1237
  test("Find folders explains when a lower threshold needs a global scan", async () => {
1103
1238
  const message = "Run a global scan with this minimum file size before searching folders."
1104
1239
  const { dom, requests } = await makePage(fixture([item()]), {
@@ -1121,9 +1256,8 @@ describe("Save Space interface", () => {
1121
1256
  assert.equal(document.querySelector(
1122
1257
  ".vault-find-partial[role='alert']").textContent.trim(), message)
1123
1258
  assert.ok(document.querySelector("[data-find-home-folder]"))
1124
- assert.equal(requests.find((request) =>
1125
- request.action === "find_folders").candidate_size,
1126
- 50 * candidateBase ** 2)
1259
+ assert.equal("candidate_size" in requests.find((request) =>
1260
+ request.action === "find_folders"), false)
1127
1261
  dom.window.close()
1128
1262
  })
1129
1263