pinokiod 8.0.58 → 8.0.61

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.
@@ -10,7 +10,7 @@ const {
10
10
  } = require("./operation_errors")
11
11
 
12
12
  const COMPLETE_PHASES = new Set(["complete", "completed_with_exclusions"])
13
- const STOP_SETTLE_MS = 3000
13
+ const STOP_SETTLE_MS = 1000
14
14
  const PROGRESS_INTERVAL_MS = 5000
15
15
  const isMissing = (error) => !!(error &&
16
16
  (error.code === "ENOENT" || error.code === "ENOTDIR"))
@@ -41,6 +41,7 @@ class AutomaticScans {
41
41
  this.waitingFor = null
42
42
  this.listeners = new Set()
43
43
  this.appTransitions = new Map()
44
+ this.globalScanReady = false
44
45
  }
45
46
 
46
47
  log(event, details = {}) {
@@ -134,7 +135,8 @@ class AutomaticScans {
134
135
  snapshot() {
135
136
  return {
136
137
  enabled: !!this.vault.enabled,
137
- rows: [...this.entries.values()]
138
+ global_scan_ready: this.globalScanReady,
139
+ rows: (this.globalScanReady ? [...this.entries.values()] : [])
138
140
  .filter((entry) => !entry.hidden)
139
141
  .sort((left, right) =>
140
142
  (Number(left.updated_at) || 0) -
@@ -147,6 +149,14 @@ class AutomaticScans {
147
149
  }
148
150
  }
149
151
 
152
+ async refreshGlobalScanReady() {
153
+ const ready = typeof this.vault.globalScanReady === "function" &&
154
+ !!await this.vault.globalScanReady()
155
+ const changed = ready !== this.globalScanReady
156
+ this.globalScanReady = ready
157
+ return changed
158
+ }
159
+
150
160
  modeFor(app) {
151
161
  const setting = this.settings.get(app)
152
162
  return setting && setting.mode === "manual" ? "manual" : "automatic"
@@ -236,6 +246,10 @@ class AutomaticScans {
236
246
  }
237
247
 
238
248
  queueApp(app) {
249
+ if (!this.globalScanReady) {
250
+ this.log("queue-skipped", { app, reason: "global-scan-required" })
251
+ return false
252
+ }
239
253
  if (this.modeFor(app) === "manual") {
240
254
  this.log("queue-skipped", { app, reason: "manual" })
241
255
  return false
@@ -324,6 +338,12 @@ class AutomaticScans {
324
338
  this.log("preparing", { app })
325
339
  await this.vault.ensureRegistryInitialized()
326
340
  if (this.pendingStops.get(app) !== pending) return
341
+ await this.refreshGlobalScanReady()
342
+ if (!this.globalScanReady) {
343
+ this.pendingStops.delete(app)
344
+ this.log("check-skipped", { app, reason: "global-scan-required" })
345
+ return
346
+ }
327
347
  await this.hydrate()
328
348
  await this.withAppTransition(app, async () => {
329
349
  if (this.pendingStops.get(app) !== pending) return
@@ -395,6 +415,11 @@ class AutomaticScans {
395
415
  if (!this.hydrated && typeof this.vault.automaticScanStatus === "function") {
396
416
  await this.vault.automaticScanStatus()
397
417
  }
418
+ await this.refreshGlobalScanReady()
419
+ if (!this.globalScanReady) {
420
+ this.log("check-skipped", { app, reason: "global-scan-required" })
421
+ return
422
+ }
398
423
  if (this.modeFor(app) === "manual") {
399
424
  this.log("check-skipped", { app, reason: "manual" })
400
425
  return
@@ -547,14 +572,16 @@ class AutomaticScans {
547
572
 
548
573
  async drain() {
549
574
  if (!this.vault.enabled || !this.vault.registry || this.active) return
575
+ const entry = [...this.entries.values()].find((item) =>
576
+ item.state === "checking")
577
+ if (!entry) return
578
+ await this.refreshGlobalScanReady()
579
+ if (!this.globalScanReady) return
550
580
  if (this.manualDepth > 0 || this.currentBusyPromise() ||
551
581
  this.vault.fileActionProgress) {
552
582
  this.waitForBusyWork()
553
583
  return
554
584
  }
555
- const entry = [...this.entries.values()].find((item) =>
556
- item.state === "checking")
557
- if (!entry) return
558
585
  let appAvailable
559
586
  try {
560
587
  appAvailable = await this.appRootIsAvailable(entry.app)
@@ -842,6 +869,7 @@ class AutomaticScans {
842
869
 
843
870
  async scanFinished({ scopeId, result, error }) {
844
871
  const complete = !error && result && COMPLETE_PHASES.has(result.outcome)
872
+ let readinessChanged = false
845
873
  try {
846
874
  if (complete) {
847
875
  await this.hydrate()
@@ -852,7 +880,9 @@ class AutomaticScans {
852
880
  { cancelPending: true, states: ["checking", "result"] }
853
881
  )
854
882
  }
883
+ if (!scopeId) readinessChanged = await this.refreshGlobalScanReady()
855
884
  } finally {
885
+ if (readinessChanged) this.broadcast()
856
886
  this.schedule()
857
887
  }
858
888
  }
@@ -897,6 +927,16 @@ class AutomaticScans {
897
927
  await this.persistMode(app, mode)
898
928
  const entry = this.entries.get(app)
899
929
  if (entry && entry.state === "paused") this.entries.delete(app)
930
+ await this.refreshGlobalScanReady()
931
+ if (!this.globalScanReady) {
932
+ this.log("mode-changed", {
933
+ app,
934
+ mode,
935
+ check: "global-scan-required"
936
+ })
937
+ this.broadcast()
938
+ return { app, mode }
939
+ }
900
940
  if (!this.appIsRunning(app)) {
901
941
  this.log("mode-changed", { app, mode })
902
942
  this.queueApp(app)
@@ -73,6 +73,9 @@ const STATUS_FILTERS = new Set([
73
73
  const FOLDER_DISCOVERY_COMPLETE_PHASES = new Set([
74
74
  "complete", "completed_with_exclusions"
75
75
  ])
76
+ const GLOBAL_SCAN_READY_OUTCOMES = new Set([
77
+ "complete", "completed_with_exclusions"
78
+ ])
76
79
 
77
80
  const isMissingError = (error) => !!(error &&
78
81
  (error.code === "ENOENT" || error.code === "ENOTDIR"))
@@ -672,9 +675,23 @@ class Vault {
672
675
  await this.ensureRegistryInitialized()
673
676
  }
674
677
  await this.automaticScans.hydrate()
678
+ await this.automaticScans.refreshGlobalScanReady()
675
679
  return this.automaticScans.snapshot()
676
680
  }
677
681
 
682
+ async globalScanReady() {
683
+ if (!this.registry) return false
684
+ const scan = await this.registry.scanFor()
685
+ if (scan) this.lastScanCache.set("", scan)
686
+ else this.lastScanCache.delete("")
687
+ return this.globalScanIsReady(scan)
688
+ }
689
+
690
+ globalScanIsReady(scan = this.lastScanCache.get("")) {
691
+ return !!(scan && scan.ts &&
692
+ GLOBAL_SCAN_READY_OUTCOMES.has(scan.outcome))
693
+ }
694
+
678
695
  async refreshAnchorStores() {
679
696
  let config = this.readConfig()
680
697
  const candidates = [
@@ -1790,9 +1807,19 @@ class Vault {
1790
1807
  return this.runMutation(() =>
1791
1808
  this.removeExternalSource(payload.source_id))
1792
1809
  case "scan": {
1793
- if (payload.scope_id && !this.scanSource(payload.scope_id)) {
1810
+ const scanSource = payload.scope_id
1811
+ ? this.scanSource(payload.scope_id)
1812
+ : null
1813
+ if (payload.scope_id && !scanSource) {
1794
1814
  return { error: "That scan location is no longer available." }
1795
1815
  }
1816
+ if (scanSource && scanSource.kind === "app" &&
1817
+ !await this.globalScanReady()) {
1818
+ return {
1819
+ error: "Run an initial scan before scanning individual apps.",
1820
+ code: "global_scan_required"
1821
+ }
1822
+ }
1796
1823
  let threshold = this.sizeThreshold
1797
1824
  if (payload.candidate_size != null) {
1798
1825
  if (!CANDIDATE_SIZE_OPTIONS.includes(payload.candidate_size)) {
@@ -3266,6 +3293,9 @@ class Vault {
3266
3293
  }
3267
3294
 
3268
3295
  const lastScan = await this.scanForScope(scopeId)
3296
+ const globalScanReady = scopeId
3297
+ ? await this.globalScanReady()
3298
+ : this.globalScanIsReady(lastScan)
3269
3299
  const before = lastScan && Number.isFinite(lastScan.bytes_total)
3270
3300
  ? lastScan.bytes_total
3271
3301
  : 0
@@ -3302,6 +3332,7 @@ class Vault {
3302
3332
  const sourceCounts = this.sourceCountMaps(snapshot.scopeRows)
3303
3333
  const result = {
3304
3334
  enabled: true,
3335
+ global_scan_ready: globalScanReady,
3305
3336
  mode: this.mode,
3306
3337
  scan: this.scanStatus(),
3307
3338
  last_scan: lastScan,
@@ -3402,6 +3433,7 @@ class Vault {
3402
3433
  async progressStatus(scopeId = null) {
3403
3434
  const result = {
3404
3435
  enabled: !!this.enabled,
3436
+ global_scan_ready: this.globalScanIsReady(),
3405
3437
  scan: this.scanStatus(),
3406
3438
  file_action: this.fileActionStatus(scopeId),
3407
3439
  last_scan: this.lastScanCache.get(scopeId || "") || null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.58",
3
+ "version": "8.0.61",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server/index.js CHANGED
@@ -1994,9 +1994,12 @@ class Server {
1994
1994
  if (vault && vault.ready) await vault.ready
1995
1995
  result.vault_enabled = !!(vault && vault.enabled)
1996
1996
  result.vault_automatic_mode = "automatic"
1997
+ result.vault_global_scan_ready = false
1997
1998
  if (result.vault_enabled) {
1998
1999
  try {
1999
2000
  const snapshot = await vault.automaticScanStatus()
2001
+ result.vault_global_scan_ready =
2002
+ snapshot && snapshot.global_scan_ready === true
2000
2003
  const setting = Array.isArray(snapshot && snapshot.settings)
2001
2004
  ? snapshot.settings.find((item) =>
2002
2005
  item && item.app === name)
@@ -5,14 +5,22 @@
5
5
  const app = status.dataset.app || ""
6
6
  const tab = status.closest("#save-space-tab")
7
7
  const label = status.querySelector("[data-app-vault-mode-label]")
8
- const setMode = (value) => {
8
+ const setState = (value, ready) => {
9
9
  const mode = value === "manual" ? "manual" : "automatic"
10
+ const globalScanReady = ready === true
10
11
  status.dataset.mode = mode
12
+ status.dataset.ready = String(globalScanReady)
11
13
  status.hidden = false
12
- if (label) label.textContent = mode === "automatic" ? "Auto" : "Manual"
14
+ if (label) {
15
+ label.textContent = globalScanReady
16
+ ? (mode === "automatic" ? "Auto" : "Manual")
17
+ : "Set up"
18
+ }
13
19
  if (tab) {
14
20
  tab.setAttribute("aria-label",
15
- `Disk Saver — ${mode === "automatic" ? "Automatic" : "Manual"} checking`)
21
+ globalScanReady
22
+ ? `Disk Saver — ${mode === "automatic" ? "Automatic" : "Manual"} checking`
23
+ : "Disk Saver — Set up required")
16
24
  }
17
25
  }
18
26
  const applySnapshot = (snapshot) => {
@@ -20,10 +28,11 @@
20
28
  ? snapshot.settings
21
29
  : []
22
30
  const setting = settings.find((item) => item && item.app === app)
23
- setMode(setting && setting.mode)
31
+ setState(setting && setting.mode,
32
+ snapshot && snapshot.global_scan_ready === true)
24
33
  }
25
34
 
26
- setMode(status.dataset.mode)
35
+ setState(status.dataset.mode, status.dataset.ready === "true")
27
36
  if (!app || typeof window.EventSource !== "function") return
28
37
 
29
38
  const source = new window.EventSource(
@@ -1068,6 +1068,13 @@
1068
1068
  }
1069
1069
 
1070
1070
  function render(snapshot, options = {}) {
1071
+ if (!snapshot || snapshot.global_scan_ready !== true) {
1072
+ [...completions.keys()].forEach(removeCompletion);
1073
+ visibleCheckingApps = new Set();
1074
+ tray.replaceChildren();
1075
+ tray.hidden = true;
1076
+ return;
1077
+ }
1071
1078
  const rows = snapshot && Array.isArray(snapshot.rows)
1072
1079
  ? snapshot.rows
1073
1080
  : [];
@@ -218,6 +218,31 @@ body[data-vault-mode="app"] .vault-overview {
218
218
  letter-spacing: -.025em;
219
219
  line-height: 1.15;
220
220
  }
221
+ .vault-setup-copy {
222
+ max-width: 560px;
223
+ margin: 8px 0 0;
224
+ color: var(--task-muted);
225
+ font-size: 12px;
226
+ line-height: 1.5;
227
+ }
228
+ .vault-body.setup-required .vault-overview {
229
+ min-height: 260px;
230
+ flex: 1 1 auto;
231
+ justify-content: center;
232
+ gap: 32px;
233
+ padding: 48px;
234
+ border-bottom: 0;
235
+ }
236
+ .vault-body.setup-required .vault-metrics.summary {
237
+ max-width: 620px;
238
+ flex: 0 1 620px;
239
+ grid-template-columns: minmax(0, 1fr);
240
+ }
241
+ .vault-body.setup-required .vault-summary-value {
242
+ margin-top: 8px;
243
+ font-size: 24px;
244
+ }
245
+ .vault-body.setup-required .vault-summary-side { display: none; }
221
246
  .vault-storage-chart {
222
247
  width: min(100%, 540px);
223
248
  min-width: 0;
@@ -105,6 +105,9 @@ const COPY = {
105
105
  never: "Never",
106
106
  scan: "Scan",
107
107
  scan_app: "Scan this app",
108
+ setup_disk_saver: "Set up Disk Saver",
109
+ setup_title: "Run an initial scan to enable app scans",
110
+ setup_description: "This creates the file index used to compare apps and enables automatic checks.",
108
111
  scan_again: "Scan again",
109
112
  cancel_scan: "Cancel scan",
110
113
  scanning: "Scanning…",
@@ -533,6 +536,9 @@ const post = async (payload) => {
533
536
  if (!result) throw new Error(COPY.action_request_failed.replace("{status}", response.status))
534
537
  return result
535
538
  }
539
+ const openGlobalWorkspace = () => {
540
+ window.parent.location.assign("/vault")
541
+ }
536
542
  let automaticModeEventSource = null
537
543
  const applyAutomaticScanSnapshot = (snapshot) => {
538
544
  if (!IS_APP_MODE) return
@@ -543,9 +549,17 @@ const applyAutomaticScanSnapshot = (snapshot) => {
543
549
  const mode = setting && setting.mode === "manual"
544
550
  ? "manual"
545
551
  : "automatic"
546
- if (state.automaticMode === mode) return
552
+ const globalScanReady = !!(snapshot &&
553
+ snapshot.global_scan_ready === true)
554
+ const readinessChanged = state.data &&
555
+ state.data.global_scan_ready !== globalScanReady
556
+ if (state.data) state.data.global_scan_ready = globalScanReady
557
+ if (state.automaticMode === mode && !readinessChanged) return
547
558
  state.automaticMode = mode
548
- if (state.data) renderOverview()
559
+ if (state.data) {
560
+ if (readinessChanged) refresh(true)
561
+ else renderOverview()
562
+ }
549
563
  }
550
564
  const loadAutomaticMode = async () => {
551
565
  if (!IS_APP_MODE) return
@@ -2176,7 +2190,51 @@ const automaticModeMarkup = () => {
2176
2190
  </details>`
2177
2191
  }
2178
2192
 
2179
- const renderOverview = () => {
2193
+ const appSetupRequired = () => IS_APP_MODE && state.data &&
2194
+ state.data.global_scan_ready !== true
2195
+
2196
+ const restoreAutomaticModeMenu = (open) => {
2197
+ const modeMenu = el("vault-auto-mode")
2198
+ if (modeMenu && open) modeMenu.open = true
2199
+ if (!modeMenu || !state.automaticModeMenuRequested) return
2200
+ state.automaticModeMenuRequested = false
2201
+ modeMenu.classList.add("targeted")
2202
+ const trigger = modeMenu.querySelector("summary")
2203
+ requestAnimationFrame(() => {
2204
+ if (trigger && trigger.isConnected) trigger.focus()
2205
+ })
2206
+ setTimeout(() => {
2207
+ if (modeMenu.isConnected) modeMenu.classList.remove("targeted")
2208
+ }, 1600)
2209
+ }
2210
+
2211
+ const renderSetupOverview = () => {
2212
+ const metrics = el("vault-metrics")
2213
+ const existingModeMenu = el("vault-auto-mode")
2214
+ const modeMenuOpen = !!(existingModeMenu && existingModeMenu.open) ||
2215
+ state.automaticModeMenuRequested
2216
+ metrics.classList.add("summary")
2217
+ metrics.innerHTML = `
2218
+ <div class="vault-summary-main">
2219
+ <div class="vault-summary-label"><i class="fa-solid fa-hard-drive"></i><span>${esc(COPY.save_space)}</span>${automaticModeMarkup()}</div>
2220
+ <div class="vault-summary-value">${esc(COPY.setup_title)}</div>
2221
+ <p class="vault-setup-copy">${esc(COPY.setup_description)}</p>
2222
+ </div>
2223
+ <div class="vault-summary-side"></div>`
2224
+ restoreAutomaticModeMenu(modeMenuOpen)
2225
+
2226
+ const scanButton = el("btn-scan")
2227
+ scanButton.innerHTML = `<i class="fa-solid fa-arrow-right" aria-hidden="true"></i>${esc(COPY.setup_disk_saver)}`
2228
+ scanButton.classList.add("primary")
2229
+ scanButton.disabled = false
2230
+ const candidateSizeSelect = el("vault-candidate-size")
2231
+ if (candidateSizeSelect) candidateSizeSelect.hidden = true
2232
+ const scanState = el("vault-scan-state")
2233
+ scanState.classList.remove("show")
2234
+ scanState.innerHTML = ""
2235
+ }
2236
+
2237
+ const renderNormalOverview = () => {
2180
2238
  const data = state.data
2181
2239
  const last = data.last_scan
2182
2240
  const activeScan = scanActive(data.scan)
@@ -2251,19 +2309,7 @@ const renderOverview = () => {
2251
2309
  ${summary}
2252
2310
  </div>
2253
2311
  <div class="vault-summary-side">${summarySide}</div>`
2254
- const modeMenu = el("vault-auto-mode")
2255
- if (modeMenu && modeMenuOpen) modeMenu.open = true
2256
- if (modeMenu && state.automaticModeMenuRequested) {
2257
- state.automaticModeMenuRequested = false
2258
- modeMenu.classList.add("targeted")
2259
- const trigger = modeMenu.querySelector("summary")
2260
- requestAnimationFrame(() => {
2261
- if (trigger && trigger.isConnected) trigger.focus()
2262
- })
2263
- setTimeout(() => {
2264
- if (modeMenu.isConnected) modeMenu.classList.remove("targeted")
2265
- }, 1600)
2266
- }
2312
+ restoreAutomaticModeMenu(modeMenuOpen)
2267
2313
  }
2268
2314
  const idleScanLabel = IS_APP_MODE
2269
2315
  ? (last ? COPY.scan_again : COPY.scan_app)
@@ -2289,7 +2335,10 @@ const renderOverview = () => {
2289
2335
  })
2290
2336
  }
2291
2337
  const candidateSizeSelect = el("vault-candidate-size")
2292
- if (candidateSizeSelect) candidateSizeSelect.disabled = activeScan
2338
+ if (candidateSizeSelect) {
2339
+ candidateSizeSelect.hidden = false
2340
+ candidateSizeSelect.disabled = activeScan
2341
+ }
2293
2342
  const scanControl = el("vault-scan-control")
2294
2343
  if (scanControl) scanControl.classList.toggle("single", activeScan)
2295
2344
  const scanSizeMenu = el("vault-scan-size-menu")
@@ -2369,6 +2418,14 @@ const renderOverview = () => {
2369
2418
  }
2370
2419
  }
2371
2420
 
2421
+ const renderOverview = () => {
2422
+ if (appSetupRequired()) {
2423
+ renderSetupOverview()
2424
+ return
2425
+ }
2426
+ renderNormalOverview()
2427
+ }
2428
+
2372
2429
  const renderResult = () => {
2373
2430
  const result = el("vault-result")
2374
2431
  if (!state.scanResult) {
@@ -2555,11 +2612,34 @@ const renderCleanupNotice = () => {
2555
2612
  notice.innerHTML = `<i class="fa-solid fa-broom" aria-hidden="true"></i><strong>${esc(title)}</strong><span class="vault-cleanup-notice-detail">${esc(detail)}</span><button class="vault-button" id="btn-review-cleanup" type="button">${esc(COPY.review_cleanup)}<i class="fa-solid fa-chevron-right" aria-hidden="true"></i></button>`
2556
2613
  }
2557
2614
 
2615
+ const clearPanel = (id, className) => {
2616
+ const panel = el(id)
2617
+ panel.className = className
2618
+ panel.innerHTML = ""
2619
+ }
2620
+
2621
+ const renderSetupWorkspace = () => {
2622
+ renderSetupOverview()
2623
+ renderFeedback()
2624
+ el("vault-explorer").style.display = "none"
2625
+ clearPanel("vault-action-state", "vault-action-state")
2626
+ clearPanel("vault-result", "vault-result")
2627
+ clearPanel("vault-cleanup-notice", "vault-cleanup-notice")
2628
+ renderedActionProgress = null
2629
+ }
2630
+
2558
2631
  const render = () => {
2559
2632
  if (!state.data) return
2633
+ const setupRequired = appSetupRequired()
2634
+ const body = document.querySelector(".vault-body")
2635
+ if (body) body.classList.toggle("setup-required", setupRequired)
2636
+ if (setupRequired) {
2637
+ renderSetupWorkspace()
2638
+ return
2639
+ }
2560
2640
  renderOverview()
2561
- renderResult()
2562
2641
  renderFeedback()
2642
+ renderResult()
2563
2643
  renderCleanupNotice()
2564
2644
  if (!state.data.enabled) {
2565
2645
  el("vault-explorer").style.display = "none"
@@ -2738,12 +2818,16 @@ const refresh = async (forceFull = false) => {
2738
2818
  folderDiscoveryActive(progress.folder_discovery) ||
2739
2819
  fileAction) {
2740
2820
  delay = 1500
2741
- renderOverview()
2742
- renderResult()
2743
- renderActionProgress()
2744
- renderFeedback()
2745
- renderExternalPrompt()
2746
- renderFolderDiscovery()
2821
+ if (appSetupRequired()) {
2822
+ renderSetupWorkspace()
2823
+ } else {
2824
+ renderOverview()
2825
+ renderResult()
2826
+ renderActionProgress()
2827
+ renderFeedback()
2828
+ renderExternalPrompt()
2829
+ renderFolderDiscovery()
2830
+ }
2747
2831
  } else {
2748
2832
  const data = await fetchJson(statusUrl())
2749
2833
  if (sequence !== refreshSequence) return
@@ -3553,6 +3637,10 @@ document.addEventListener("click", async (event) => {
3553
3637
  }
3554
3638
  return
3555
3639
  }
3640
+ if (appSetupRequired()) {
3641
+ openGlobalWorkspace()
3642
+ return
3643
+ }
3556
3644
  state.scanRequested = true
3557
3645
  state.scanBaseline = state.data.last_scan ? state.data.last_scan.ts : null
3558
3646
  state.scanResult = null
@@ -4121,12 +4121,18 @@ body.dark .disk-usage {
4121
4121
  .app-vault-mode[data-mode="automatic"] {
4122
4122
  color: #b45309;
4123
4123
  }
4124
+ .app-vault-mode[data-ready="false"] {
4125
+ color: #b45309;
4126
+ }
4124
4127
  body.dark .app-vault-mode {
4125
4128
  color: rgba(203, 213, 225, 0.74);
4126
4129
  }
4127
4130
  body.dark .app-vault-mode[data-mode="automatic"] {
4128
4131
  color: #e0a54b;
4129
4132
  }
4133
+ body.dark .app-vault-mode[data-ready="false"] {
4134
+ color: #e0a54b;
4135
+ }
4130
4136
  .app-vault-mode-chevron,
4131
4137
  .app-autolaunch-chevron {
4132
4138
  display: inline-flex;
@@ -8501,13 +8507,14 @@ body.dark .pinokio-custom-terminal-header {
8501
8507
  </button>
8502
8508
  <% if (typeof vault_enabled !== 'undefined' && vault_enabled) { %>
8503
8509
  <% const vaultAutomaticMode = typeof vault_automatic_mode === 'string' && vault_automatic_mode === 'manual' ? 'manual' : 'automatic' %>
8504
- <a id='save-space-tab' data-mode="refresh" target="app-vault" href="/vault/app/<%=encodeURIComponent(name)%>" class="btn header-item frame-link" data-index="vault" data-static="retain" data-tab-link-popover="false" aria-label="Disk Saver — <%=vaultAutomaticMode === 'automatic' ? 'Automatic' : 'Manual'%> checking">
8510
+ <% const vaultGlobalScanReady = typeof vault_global_scan_ready !== 'undefined' && vault_global_scan_ready === true %>
8511
+ <a id='save-space-tab' data-mode="refresh" target="app-vault" href="/vault/app/<%=encodeURIComponent(name)%>" class="btn header-item frame-link" data-index="vault" data-static="retain" data-tab-link-popover="false" aria-label="<%=vaultGlobalScanReady ? `Disk Saver — ${vaultAutomaticMode === 'automatic' ? 'Automatic' : 'Manual'} checking` : 'Disk Saver — Set up required'%>">
8505
8512
  <div class='tab'>
8506
8513
  <i class="fa-solid fa-hard-drive menu-action-leading-icon"></i>
8507
8514
  <div class='display'>Disk Saver</div>
8508
8515
  <div class='flexible'></div>
8509
- <span class="app-vault-mode" data-app-vault-mode data-app="<%=name%>" data-mode="<%=vaultAutomaticMode%>" aria-hidden="true">
8510
- <span data-app-vault-mode-label><%=vaultAutomaticMode === 'automatic' ? 'Auto' : 'Manual'%></span>
8516
+ <span class="app-vault-mode" data-app-vault-mode data-app="<%=name%>" data-mode="<%=vaultAutomaticMode%>" data-ready="<%=vaultGlobalScanReady ? 'true' : 'false'%>" aria-hidden="true">
8517
+ <span data-app-vault-mode-label><%=vaultGlobalScanReady ? (vaultAutomaticMode === 'automatic' ? 'Auto' : 'Manual') : 'Set up'%></span>
8511
8518
  </span>
8512
8519
  <i class="fa-solid fa-angle-down app-vault-mode-chevron" aria-hidden="true"></i>
8513
8520
  </div>
@@ -50,6 +50,7 @@ test("the shared layout renders and reviews automatic possible-match notices", a
50
50
  status: 200,
51
51
  json: async () => ({
52
52
  enabled: true,
53
+ global_scan_ready: true,
53
54
  rows: []
54
55
  })
55
56
  }
@@ -72,6 +73,22 @@ test("the shared layout renders and reviews automatic possible-match notices", a
72
73
  eventSources[0].onmessage({
73
74
  data: JSON.stringify({
74
75
  enabled: true,
76
+ global_scan_ready: false,
77
+ rows: [{
78
+ app: "ComfyUI",
79
+ state: "checking",
80
+ notice_id: "checking:0:"
81
+ }]
82
+ })
83
+ })
84
+ assert.equal(dom.window.document.getElementById(
85
+ "vault-auto-scan-tray").hidden, true)
86
+ assert.equal(dom.window.document.querySelector(
87
+ ".vault-auto-scan-row"), null)
88
+ eventSources[0].onmessage({
89
+ data: JSON.stringify({
90
+ enabled: true,
91
+ global_scan_ready: true,
75
92
  rows: [{
76
93
  app: "ComfyUI",
77
94
  state: "result",
@@ -171,6 +188,7 @@ test("checking notices expose settings, Pause, and dismissal", async () => {
171
188
  eventSources[0].onmessage({
172
189
  data: JSON.stringify({
173
190
  enabled: true,
191
+ global_scan_ready: true,
174
192
  rows: [{
175
193
  app: "ComfyUI",
176
194
  state: "checking",
@@ -286,6 +304,7 @@ test("an empty automatic check briefly confirms completion", async () => {
286
304
  status: 200,
287
305
  json: async () => ({
288
306
  enabled: true,
307
+ global_scan_ready: true,
289
308
  rows: [],
290
309
  settings: [{ app: "ComfyUI", mode: "automatic" }]
291
310
  })
@@ -304,7 +323,7 @@ test("an empty automatic check briefly confirms completion", async () => {
304
323
  dom.window.eval(script)
305
324
  await waitFor(() => eventSources.length === 1)
306
325
  const send = (payload) => eventSources[0].onmessage({
307
- data: JSON.stringify(payload)
326
+ data: JSON.stringify(Object.assign({ global_scan_ready: true }, payload))
308
327
  })
309
328
 
310
329
  send({
@@ -527,7 +546,7 @@ test("the app sidebar mirrors Automatic and Manual Disk Saver modes", async () =
527
546
  const script = await fs.promises.readFile(
528
547
  path.join(root, "server", "public", "app-vault-mode.js"), "utf8")
529
548
  const dom = new JSDOM(`<a id="save-space-tab">
530
- <span data-app-vault-mode data-app="ComfyUI" data-mode="automatic">
549
+ <span data-app-vault-mode data-app="ComfyUI" data-mode="automatic" data-ready="true">
531
550
  <span data-app-vault-mode-label>Auto</span>
532
551
  </span>
533
552
  </a>`, {
@@ -557,6 +576,7 @@ test("the app sidebar mirrors Automatic and Manual Disk Saver modes", async () =
557
576
 
558
577
  eventSources[0].onmessage({
559
578
  data: JSON.stringify({
579
+ global_scan_ready: true,
560
580
  settings: [{ app: "ComfyUI", mode: "manual" }]
561
581
  })
562
582
  })
@@ -566,10 +586,32 @@ test("the app sidebar mirrors Automatic and Manual Disk Saver modes", async () =
566
586
  assert.equal(dom.window.document.getElementById("save-space-tab")
567
587
  .getAttribute("aria-label"), "Disk Saver — Manual checking")
568
588
 
569
- eventSources[0].onmessage({ data: JSON.stringify({ settings: [] }) })
589
+ eventSources[0].onmessage({
590
+ data: JSON.stringify({ global_scan_ready: true, settings: [] })
591
+ })
570
592
  assert.equal(status.dataset.mode, "automatic")
571
593
  assert.equal(status.hidden, false)
572
594
  assert.equal(label.textContent, "Auto")
595
+
596
+ eventSources[0].onmessage({
597
+ data: JSON.stringify({
598
+ global_scan_ready: false,
599
+ settings: [{ app: "ComfyUI", mode: "automatic" }]
600
+ })
601
+ })
602
+ assert.equal(status.dataset.ready, "false")
603
+ assert.equal(label.textContent, "Set up")
604
+ assert.equal(dom.window.document.getElementById("save-space-tab")
605
+ .getAttribute("aria-label"), "Disk Saver — Set up required")
606
+
607
+ eventSources[0].onmessage({
608
+ data: JSON.stringify({
609
+ global_scan_ready: true,
610
+ settings: [{ app: "ComfyUI", mode: "automatic" }]
611
+ })
612
+ })
613
+ assert.equal(status.dataset.ready, "true")
614
+ assert.equal(label.textContent, "Auto")
573
615
  assert.match(template, /data-app-vault-mode/)
574
616
  assert.match(template, /app-vault-mode\.js/)
575
617
  assert.match(template,
@@ -592,6 +634,7 @@ test("the app sidebar mirrors Automatic and Manual Disk Saver modes", async () =
592
634
  assert.doesNotMatch(template, /app-vault-mode-dot/)
593
635
  assert.match(server,
594
636
  /result\.vault_automatic_mode = setting && setting\.mode === "manual"/)
637
+ assert.match(server, /result\.vault_global_scan_ready/)
595
638
 
596
639
  dom.window.close()
597
640
  })
@@ -39,6 +39,11 @@ const makeVault = async (home, options = {}) => {
39
39
  } else {
40
40
  await vault.init()
41
41
  }
42
+ const globalScanReady = options.globalScanReady !== undefined
43
+ ? !!options.globalScanReady
44
+ : true
45
+ vault.globalScanReady = async () => globalScanReady
46
+ vault.automaticScans.globalScanReady = globalScanReady
42
47
  return vault
43
48
  }
44
49
 
@@ -75,14 +80,32 @@ describe("automatic app checks", () => {
75
80
 
76
81
  test("reading notice state does not create Vault storage", async () => {
77
82
  const home = await makeHome()
78
- const vault = await makeVault(home, { deferStorage: true })
83
+ const vault = await makeVault(home, {
84
+ deferStorage: true,
85
+ globalScanReady: false
86
+ })
79
87
 
80
- assert.deepEqual((await vault.automaticScanStatus()).rows, [])
88
+ const status = await vault.automaticScanStatus()
89
+ assert.equal(status.global_scan_ready, false)
90
+ assert.deepEqual(status.rows, [])
81
91
  assert.equal(vault.initialized, false)
82
92
  assert.equal(fs.existsSync(path.join(home, "vault")), false)
83
93
  await close(vault)
84
94
  })
85
95
 
96
+ test("missing readiness state keeps automatic checks locked", async () => {
97
+ const automatic = new AutomaticScans({ enabled: true })
98
+
99
+ await automatic.refreshGlobalScanReady()
100
+
101
+ assert.equal(automatic.globalScanReady, false)
102
+ assert.deepEqual(automatic.snapshot().rows, [])
103
+ })
104
+
105
+ test("automatic checks wait one second after an app stops", () => {
106
+ assert.equal(AutomaticScans.STOP_SETTLE_MS, 1000)
107
+ })
108
+
86
109
  test("disabled Vault ignores app-stop events", async () => {
87
110
  const home = await makeHome()
88
111
  const appRoot = path.join(home, "api", "disabled-app")
@@ -111,6 +134,7 @@ describe("automatic app checks", () => {
111
134
  api: { running_paths: {} }
112
135
  },
113
136
  sources: () => [],
137
+ globalScanReady: async () => true,
114
138
  automaticScanStatus: async () => ({ rows: [] })
115
139
  }
116
140
  const automatic = new AutomaticScans(vault)
@@ -840,6 +864,7 @@ describe("automatic app checks", () => {
840
864
  }
841
865
  }
842
866
  })
867
+ automatic.globalScanReady = true
843
868
  automatic.hydrated = true
844
869
  automatic.log = () => {}
845
870
  automatic.active = {
@@ -904,6 +929,7 @@ describe("automatic app checks", () => {
904
929
  }
905
930
  }
906
931
  })
932
+ automatic.globalScanReady = true
907
933
  automatic.hydrated = true
908
934
  automatic.log = () => {}
909
935
  automatic.entries.set("demo", {
@@ -134,6 +134,77 @@ describe("Save Space engine", () => {
134
134
  await close(vault)
135
135
  })
136
136
 
137
+ test("global readiness accepts only successful publication outcomes", async () => {
138
+ const { vault } = await makeVault()
139
+ const scan = (outcome) => ({ ts: 1, outcome })
140
+
141
+ assert.equal(vault.globalScanIsReady(scan("complete")), true)
142
+ assert.equal(vault.globalScanIsReady(
143
+ scan("completed_with_exclusions")), true)
144
+ assert.equal(vault.globalScanIsReady(scan("cancelled")), false)
145
+ assert.equal(vault.globalScanIsReady(scan("failed")), false)
146
+ assert.equal(vault.globalScanIsReady(null), false)
147
+
148
+ await close(vault)
149
+ })
150
+
151
+ test("app scans stay locked until the first global scan is published", async () => {
152
+ const { home, vault } = await makeVault()
153
+ await write(path.join(home, "api", "demo", "model.bin"), "demo")
154
+ await vault.openWorkspace()
155
+ const source = vault.sources().find((item) =>
156
+ item.kind === "app" && item.app === "demo")
157
+
158
+ assert.equal(await vault.globalScanReady(), false)
159
+ assert.equal((await vault.status(source.id)).global_scan_ready, false)
160
+ assert.deepEqual(await vault.perform("scan", {
161
+ scope_id: source.id,
162
+ candidate_size: 0
163
+ }), {
164
+ error: "Run an initial scan before scanning individual apps.",
165
+ code: "global_scan_required"
166
+ })
167
+
168
+ assert.deepEqual(await vault.perform("automatic_set_mode", {
169
+ app: "demo",
170
+ mode: "automatic"
171
+ }), { app: "demo", mode: "automatic" })
172
+ const launchPath = path.join(home, "api", "demo", "start.js")
173
+ vault.automaticScans.handleStarted(launchPath)
174
+ await vault.automaticScans.handleStopped(launchPath)
175
+ assert.equal(vault.automaticScans.pendingStops.size, 0)
176
+ assert.deepEqual(vault.automaticScans.snapshot().rows, [])
177
+
178
+ assert.equal((await vault.perform("scan", {
179
+ candidate_size: 0
180
+ })).started, true)
181
+ await waitForEngine(() =>
182
+ !vault.scanPromise && !vault.scanCompletionPromise)
183
+
184
+ assert.equal(await vault.globalScanReady(), true)
185
+ await vault.automaticScans.scanFinished({
186
+ scopeId: null,
187
+ result: { outcome: "cancelled" },
188
+ error: null
189
+ })
190
+ assert.equal(await vault.globalScanReady(), true)
191
+ await vault.automaticScans.scanFinished({
192
+ scopeId: null,
193
+ result: null,
194
+ error: new Error("scan failed")
195
+ })
196
+ assert.equal(await vault.globalScanReady(), true)
197
+ assert.equal(vault.automaticScans.active, null)
198
+ assert.deepEqual(vault.automaticScans.snapshot().rows, [])
199
+ assert.equal((await vault.perform("scan", {
200
+ scope_id: source.id,
201
+ candidate_size: 0
202
+ })).started, true)
203
+ await waitForEngine(() =>
204
+ !vault.scanPromise && !vault.scanCompletionPromise)
205
+ await close(vault)
206
+ })
207
+
137
208
  test("locations and anchor stores persist in the Disk Saver config", async () => {
138
209
  const base = await makeHome()
139
210
  const home = path.join(base, "pinokio")
@@ -903,6 +974,7 @@ describe("Save Space engine", () => {
903
974
  const outside = await makeOutside()
904
975
  const pair = await duplicatePair(home)
905
976
  await vault.sweeper.scan()
977
+ assert.equal(await vault.globalScanReady(), true)
906
978
  const duplicate = [...await vault.registry.files({
907
979
  statuses: ["duplicate"]
908
980
  })][0]
@@ -922,6 +994,7 @@ describe("Save Space engine", () => {
922
994
  replacement.sizeThreshold = pair.contents.length * 2
923
995
 
924
996
  assert.deepEqual(replacement.configuredLocations(), [])
997
+ assert.equal(await replacement.globalScanReady(), false)
925
998
  assert.equal(await replacement.registry.countFiles(), 0)
926
999
  assert.equal(fs.existsSync(anchorPath), true)
927
1000
  await replacement.sweeper.scan()
@@ -49,6 +49,7 @@ const fixture = (items = [], overrides = {}) => {
49
49
  const shared = items.filter((entry) => entry.status === "shared")
50
50
  return Object.assign({
51
51
  enabled: true,
52
+ global_scan_ready: true,
52
53
  mode: "link",
53
54
  scan: {
54
55
  active: false,
@@ -162,6 +163,7 @@ const makePage = async (status, options = {}) => {
162
163
  }
163
164
  )
164
165
  const requests = []
166
+ const parentNavigations = []
165
167
  const selectedSources = new Set(options.folderDiscoverySelected || [])
166
168
  const recommendedSources = new Set()
167
169
  const confirmations = []
@@ -249,6 +251,18 @@ const makePage = async (status, options = {}) => {
249
251
  }
250
252
  dom.window.requestAnimationFrame = (callback) =>
251
253
  dom.window.setTimeout(callback, 0)
254
+ if (options.embeddedApp) {
255
+ Object.defineProperty(dom.window, "parent", {
256
+ configurable: true,
257
+ value: {
258
+ location: {
259
+ assign(target) {
260
+ parentNavigations.push(target)
261
+ }
262
+ }
263
+ }
264
+ })
265
+ }
252
266
  if (options.fastStatusRetry) {
253
267
  const setTimeout = dom.window.setTimeout.bind(dom.window)
254
268
  dom.window.setTimeout = (callback, delay, ...args) =>
@@ -370,7 +384,8 @@ const makePage = async (status, options = {}) => {
370
384
  }
371
385
  dom.window.eval(await source(path.join(publicRoot, "storage-size.js")))
372
386
  dom.window.eval(await source(path.join(publicRoot, "vault.js")))
373
- await waitFor(() => dom.window.document.querySelector(".vault-table"))
387
+ await waitFor(() => dom.window.document.querySelector(
388
+ ".vault-summary-value"))
374
389
  const choosePickedPath = (folderPath) => {
375
390
  const pending = pendingPickers.shift()
376
391
  if (!pending) throw new Error("No folder picker is waiting for a selection.")
@@ -398,6 +413,7 @@ const makePage = async (status, options = {}) => {
398
413
  return {
399
414
  dom,
400
415
  requests,
416
+ parentNavigations,
401
417
  confirmations,
402
418
  pickerRequests,
403
419
  choosePickedPath,
@@ -444,6 +460,7 @@ describe("Save Space interface", () => {
444
460
  assert.doesNotMatch(combined, /Previous completed results were kept\./)
445
461
  assert.match(combined, /Scan completed with exclusions/)
446
462
  assert.match(combined, /Provisional until the scan completes/)
463
+ assert.match(combined, /Set up Disk Saver/)
447
464
  assert.doesNotMatch(combined, /Scan all locations/)
448
465
  assert.match(combined, /data-find-home-folder/)
449
466
  assert.match(combined, /data-find-other-folder/)
@@ -2204,6 +2221,95 @@ describe("Save Space interface", () => {
2204
2221
  dom.window.close()
2205
2222
  })
2206
2223
 
2224
+ test("an app without a global baseline shows setup and opens global Disk Saver", async () => {
2225
+ const status = fixture([], {
2226
+ global_scan_ready: false,
2227
+ last_scan: null,
2228
+ logical_bytes: 0,
2229
+ saved_by_sharing: 0,
2230
+ pending_bytes: 0
2231
+ })
2232
+ const { dom, requests, parentNavigations } = await makePage(status, {
2233
+ appMode: true,
2234
+ embeddedApp: true,
2235
+ scopeId: "app:app",
2236
+ actionResults: {
2237
+ automatic_set_mode: { app: "app", mode: "manual" }
2238
+ }
2239
+ })
2240
+ const document = dom.window.document
2241
+ const scanButton = document.getElementById("btn-scan")
2242
+
2243
+ assert.match(document.querySelector(".vault-summary-value").textContent,
2244
+ /Run an initial scan to enable app scans/)
2245
+ assert.match(document.querySelector(".vault-setup-copy").textContent,
2246
+ /creates the file index used to compare apps/)
2247
+ assert.match(scanButton.textContent, /Set up Disk Saver/)
2248
+ assert.equal(scanButton.classList.contains("primary"), true)
2249
+ assert.equal(document.getElementById("vault-candidate-size").hidden, true)
2250
+ assert.equal(document.getElementById("vault-explorer").style.display,
2251
+ "none")
2252
+ assert.doesNotMatch(document.getElementById("vault-metrics").textContent,
2253
+ /Nothing else to save/)
2254
+
2255
+ await waitFor(() => document.querySelector(
2256
+ '[data-automatic-mode="manual"]'))
2257
+ document.querySelector('[data-automatic-mode="manual"]').click()
2258
+ await waitFor(() => requests.some((request) =>
2259
+ request.action === "automatic_set_mode"))
2260
+ await waitFor(() => document.getElementById(
2261
+ "vault-auto-mode").classList.contains("manual"))
2262
+ assert.match(document.querySelector(".vault-summary-value").textContent,
2263
+ /Run an initial scan to enable app scans/)
2264
+ assert.equal(document.getElementById("vault-explorer").style.display,
2265
+ "none")
2266
+
2267
+ scanButton.click()
2268
+ assert.deepEqual(parentNavigations, ["/vault"])
2269
+ assert.equal(requests.some((request) => request.action === "scan"), false)
2270
+
2271
+ dom.window.close()
2272
+ })
2273
+
2274
+ test("app setup remains visible during global scan progress", async () => {
2275
+ let progressRequests = 0
2276
+ const status = fixture([], {
2277
+ global_scan_ready: false,
2278
+ scan: {
2279
+ active: true,
2280
+ pending: false,
2281
+ phase: "walking",
2282
+ queued: 0,
2283
+ scope_id: null
2284
+ },
2285
+ last_scan: null,
2286
+ logical_bytes: 0,
2287
+ saved_by_sharing: 0,
2288
+ pending_bytes: 0
2289
+ })
2290
+ const { dom } = await makePage((url) => {
2291
+ if (url.includes("progress=1")) progressRequests += 1
2292
+ return status
2293
+ }, {
2294
+ appMode: true,
2295
+ scopeId: "app:app"
2296
+ })
2297
+ const document = dom.window.document
2298
+
2299
+ for (let attempt = 0; attempt < 400 && !progressRequests; attempt++) {
2300
+ await new Promise((resolve) => setTimeout(resolve, 5))
2301
+ }
2302
+ assert.ok(progressRequests)
2303
+ assert.match(document.querySelector(".vault-summary-value").textContent,
2304
+ /Run an initial scan to enable app scans/)
2305
+ assert.equal(document.getElementById("vault-explorer").style.display,
2306
+ "none")
2307
+ assert.equal(document.getElementById("vault-result").textContent, "")
2308
+ assert.equal(document.getElementById("vault-action-state").textContent, "")
2309
+
2310
+ dom.window.close()
2311
+ })
2312
+
2207
2313
  test("app mode uses full-width results without redundant locations", async () => {
2208
2314
  const status = fixture([item()])
2209
2315
  const { dom, requests } = await makePage(status, {