From ed0e2a23c73abd314404ca4e9e54bf9e22d94453 Mon Sep 17 00:00:00 2001 From: jelveh Date: Sat, 18 Jul 2026 16:57:52 -0700 Subject: [PATCH] fix: make Dashboard app uninstall honest about recents and resilient mid-flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Recently-opened apps were classified as sticky-removable, but the recent list is built from app-open history that a revoke doesn't touch, so their tiles reappeared on the next load — the very bug the uninstall gating set out to fix. Treat recents like recommended. - The confirm modal claimed every staying tile was 'provided by Puter', which is false for third-party recents; describe the actual reason. - Uninstall now marks in-flight loads stale so a fetch that started before the revoke can't resurrect the removed tile. - A pagination failure after the first page no longer discards the pages already fetched — one flaky request among N used to turn the whole grid into 'Failed to load apps'. --- src/gui/src/UI/Dashboard/TabApps.js | 70 +++++++++++++++++++---------- 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index 38076d5c7..b6aa9b8b2 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -131,10 +131,11 @@ function showUninstallModal ({ appName, appTitle, appUid, removesTile, self, $el const displayName = (appTitle || appName || '').trim(); // A recommended/recent app's tile reappears on the next load even after a // successful revoke; say so up front so the uninstall doesn't look like it - // silently failed. + // silently failed. (Recommended apps stay indefinitely; recent ones until + // they age out of the open history — don't claim more than that.) const note = removesTile ? '' - : '

This app is provided by Puter, so its icon will stay in Apps.

'; + : '

Its icon will stay in Apps for now, because this app is in your recommended or recently used list.

'; const $overlay = $(`
@@ -178,6 +179,13 @@ function showUninstallModal ({ appName, appTitle, appUid, removesTile, self, $el // Keep the persisted order free of the now-uninstalled app, but // only if the user already has a custom order (don't create one). if ( self._hasCustomOrder ) self.saveOrder(); + // Mark any in-flight load stale: it fetched before the revoke, + // and applying it would pop the just-removed tile back in. + // Also stop sharing its promise, so the next activation + // fetches fresh instead of joining the doomed load. + self._loadSeq = (self._loadSeq || 0) + 1; + self._appliedSeq = self._loadSeq; + self._loadPromise = null; self.renderApps($el_window, { preservePage: true }); } } catch ( err ) { @@ -977,23 +985,33 @@ const TabApps = { const MAX_PAGES = 50; // 5000 apps — a runaway backstop const all = []; for ( let page = 1; page <= MAX_PAGES; page++ ) { - const res = await fetch( - `${window.api_origin}/installedApps?orderBy=name&limit=${PAGE_SIZE}&page=${page}`, - { - headers: { 'Authorization': `Bearer ${puter.authToken}` }, - method: 'GET', - }, - ); - const batch = await res.json(); - // An error payload (e.g. `{"error": ...}` on a 401/500) must - // fail the whole load — reading it as end-of-pagination would - // silently render the grid without any installed apps. - if ( ! Array.isArray(batch) ) { - throw new Error(`installedApps returned a non-array response (status ${res.status})`); + try { + const res = await fetch( + `${window.api_origin}/installedApps?orderBy=name&limit=${PAGE_SIZE}&page=${page}`, + { + headers: { 'Authorization': `Bearer ${puter.authToken}` }, + method: 'GET', + }, + ); + const batch = await res.json(); + // An error payload (e.g. `{"error": ...}` on a 401/500) + // must fail the page — reading it as end-of-pagination + // would silently drop every installed app. + if ( ! Array.isArray(batch) ) { + throw new Error(`installedApps returned a non-array response (status ${res.status})`); + } + if ( batch.length === 0 ) break; + all.push(...batch); + if ( batch.length < PAGE_SIZE ) break; + } catch ( err ) { + // A first-page failure is a failed load. A later page + // failing must not discard the pages already fetched — + // that would turn one flaky request among N into an + // empty grid (the single-request load never had that). + if ( page === 1 ) throw err; + console.error(`Failed to fetch installedApps page ${page}; showing the ${all.length} apps fetched so far:`, err); + break; } - if ( batch.length === 0 ) break; - all.push(...batch); - if ( batch.length < PAGE_SIZE ) break; } return all; }; @@ -1026,20 +1044,26 @@ const TabApps = { })); // Whether an uninstall makes the tile disappear for good: apps the - // user actually installed that are NOT in the hardcoded recommended - // list. revokeApp on a recommended app still revokes permissions, - // but get-launch-apps re-adds the tile on the next load — the - // context menu uses this flag to set expectations in the modal - // (see showUninstallModal), not to withhold the action. + // user actually installed that are in NEITHER launch list. + // revokeApp still revokes permissions for the others, but + // get-launch-apps re-adds their tiles on the next load — the + // recommended list is hardcoded and the recent list is built from + // app-open history, which a revoke doesn't touch. The context menu + // uses this flag to set expectations in the modal (see + // showUninstallModal), not to withhold the action. const recommendedNames = new Set( (launchData.recommended || []).map(a => a.name), ); + const recentNames = new Set( + (launchData.recent || []).map(a => a.name), + ); const installedNames = new Set( (Array.isArray(installedApps) ? installedApps : []).map(a => a.name), ); const isUninstallable = name => installedNames.has(name) && ! recommendedNames.has(name) + && ! recentNames.has(name) && ! APP_NAMES_NO_UNINSTALL.has((name || '').toLowerCase()); // Build seen set from launch apps