From 036008d8a79d496f08b4ec0bd6e237bd9f78602c Mon Sep 17 00:00:00 2001 From: jelveh Date: Wed, 5 Aug 2026 11:01:14 -0700 Subject: [PATCH] fix: keep uninstalled recommended apps from resurrecting in the Apps tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uninstall only revokes permissions, but the recommended launch list is a global hardcoded set that knows nothing about per-user revokes — so an uninstalled recommended app's tile came back on every reload. Persist uninstalled app names in kv (dashboard_removed_apps) and filter only the recommended merge against them; installedApps is never filtered, so a genuinely (re)installed app always shows. --- src/gui/src/UI/Dashboard/TabApps.js | 79 +++++++++++++--- src/gui/src/UI/Dashboard/removedApps.js | 86 ++++++++++++++++++ src/gui/src/UI/Dashboard/removedApps.test.js | 95 ++++++++++++++++++++ 3 files changed, 249 insertions(+), 11 deletions(-) create mode 100644 src/gui/src/UI/Dashboard/removedApps.js create mode 100644 src/gui/src/UI/Dashboard/removedApps.test.js diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index 8fffc3e2e..fc235f9a6 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -5,6 +5,7 @@ import revokeAppSessions from '../../helpers/revoke_app_sessions.js'; import { begin_dashboard_tile_launch, settle_dashboard_tile_launch } from '../UIWindow.js'; import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js'; import { reconcileAppOrder, serializeAppOrder, mergeSavedOrder, APPS_ORDER_KV_KEY } from './appOrder.js'; +import { parseRemovedApps, serializeRemovedApps, REMOVED_APPS_KV_KEY } from './removedApps.js'; /** Lowercase app names that must not offer Uninstall in the My Apps tile context menu. */ const APP_NAMES_NO_UNINSTALL = new Set([ @@ -181,8 +182,10 @@ function showUninstallModal ({ appName, appTitle, appUid, self, $el_window }) { // // A load fetched before the revoke must not apply — it would // resurrect the pre-revoke grid. No refetch here either: the - // recommended launch list doesn't know about the revoke, so an - // immediate reload would just re-add a recommended app's tile. + // optimistic splice below already shows the result, and + // _setAppRemoved is what keeps later loads (and the next session) + // from re-adding a recommended app's tile — the recommended launch + // list is global and doesn't know about the revoke. // The saved order intentionally keeps the app's name: // reconcileAppOrder ignores it while the app is gone and // restores its position if it comes back. @@ -194,6 +197,7 @@ function showUninstallModal ({ appName, appTitle, appUid, self, $el_window }) { // also consumes the app's URL entry if it owns one). $(`.window[data-app="${html_encode(appName)}"]`).close(); self._invalidateInFlightLoads(); + self._setAppRemoved(appName, true); close(); // A failed revoke must not roll back mid-animation: finishRemoval @@ -288,6 +292,9 @@ function showUninstallModal ({ appName, appTitle, appUid, self, $el_window }) { console.error('Failed to uninstall app:', err); await removalSettled; self._invalidateInFlightLoads(); + // The uninstall didn't happen — take the name back off the + // removed list so the recommended merge can show it again. + self._setAppRemoved(appName, false); if ( removedApp && ! self._apps.some(a => a.name === appName) ) { self._apps.splice(Math.min(removedIndex, self._apps.length), 0, removedApp); self.renderApps($el_window, { preservePage: true, instant: true }); @@ -1373,6 +1380,36 @@ const TabApps = { this._loadPromise = null; }, + // Record that the user uninstalled `appName` — or, with removed=false, + // that a failed uninstall rolled back. The in-memory record makes this + // session's loads filter correctly even while the kv write is still in + // flight; the kv record is what makes the uninstall survive a refresh + // (see the removedNames filter in _fetchAndRenderApps). + _setAppRemoved (appName, removed) { + if ( typeof appName !== 'string' || appName.length === 0 ) return; + if ( ! this._removedLocal ) this._removedLocal = new Map(); + this._removedLocal.set(appName, removed); + + // Persist by read-modify-write, serialized on a promise chain so two + // quick uninstalls can't interleave their reads and writes. Every + // local mutation is replayed onto the freshly read list, so a write + // that failed earlier is repaired by the next one, and names added + // by another window survive. If the read itself fails, the write is + // skipped rather than risk clobbering the stored list with an empty + // one — the in-memory record still covers this session, and the + // next write retries the lot. + this._removedWriteChain = (this._removedWriteChain || Promise.resolve()).then(async () => { + const names = parseRemovedApps(await puter.kv.get(REMOVED_APPS_KV_KEY)); + for ( const [name, isRemoved] of this._removedLocal ) { + if ( isRemoved ) names.add(name); + else names.delete(name); + } + await puter.kv.set(REMOVED_APPS_KV_KEY, JSON.stringify(serializeRemovedApps(names))); + }).catch(err => { + console.error('Failed to persist the uninstalled-apps list:', err); + }); + }, + saveOrder () { this._hasCustomOrder = true; // Merge with the previously saved order so names absent from the @@ -1468,7 +1505,7 @@ const TabApps = { return { apps: all, complete: true }; }; - const [installedResult, launchRes, savedOrderRaw] = await Promise.all([ + const [installedResult, launchRes, savedOrderRaw, removedAppsRaw] = await Promise.all([ fetchAllInstalledApps(), fetch( `${window.api_origin}/get-launch-apps?icon_size=128`, @@ -1478,25 +1515,45 @@ const TabApps = { }, ), puter.kv.get(APPS_ORDER_KV_KEY).catch(() => null), + puter.kv.get(REMOVED_APPS_KV_KEY).catch(() => null), ]); const installedApps = installedResult.apps; const launchData = await launchRes.json(); + // Uninstall only revokes permissions, and the recommended list + // is a global hardcoded set that knows nothing about per-user + // revokes — without this filter every uninstalled recommended + // app resurrects on the next load. Only the recommended merge + // is filtered; installedApps is never touched, so an app the + // user genuinely (re)installs always shows. The kv snapshot is + // overlaid with this session's not-yet-persisted mutations: an + // uninstall races its own kv write, and a failed uninstall's + // rollback races the removal of that write. + const removedNames = parseRemovedApps(removedAppsRaw); + if ( this._removedLocal ) { + for ( const [name, isRemoved] of this._removedLocal ) { + if ( isRemoved ) removedNames.add(name); + else removedNames.delete(name); + } + } + // Normalize recommended launch apps to the tile shape. The // recent list is deliberately unused: recents are open history, // not installs, so they resurrected uninstalled apps' tiles and // showed merely-visited sites as if installed. Anything the user // actually uses appears via installedApps (opening an app grants // it a permission). Recents still power the Home tab. - const launchApps = (launchData.recommended || []).map(app => ({ - name: app.name, - title: app.title, - uid: app.uuid || app.uid || null, - index_url: app.index_url || null, - external: app.external ?? false, - iconUrl: app.iconUrl || app.icon || null, - })); + const launchApps = (launchData.recommended || []) + .filter(app => ! removedNames.has(app?.name)) + .map(app => ({ + name: app.name, + title: app.title, + uid: app.uuid || app.uid || null, + index_url: app.index_url || null, + external: app.external ?? false, + iconUrl: app.iconUrl || app.icon || null, + })); // Build seen set from launch apps const seen = new Set(); diff --git a/src/gui/src/UI/Dashboard/removedApps.js b/src/gui/src/UI/Dashboard/removedApps.js new file mode 100644 index 000000000..cf99bd697 --- /dev/null +++ b/src/gui/src/UI/Dashboard/removedApps.js @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * kv key under which the names of apps the user uninstalled from the My Apps + * grid are stored. Uninstall itself only revokes permissions, and the + * recommended launch list is a global hardcoded set that knows nothing about + * per-user revokes — this list is what keeps an uninstalled recommended app + * from resurrecting on the next load. Only the recommended merge is filtered + * against it: an app the user actually (re)installs always shows via + * installedApps. + */ +export const REMOVED_APPS_KV_KEY = 'dashboard_removed_apps'; + +/** + * Hard cap on how many uninstalled-app names are persisted. Names are short, + * so this allows years of uninstalls while bounding the kv value; past the + * cap the oldest entries fall off (new names append at the tail). + */ +export const REMOVED_APPS_MAX = 500; + +/** + * Parse the persisted removed-apps value into a Set of app names. Tolerates + * every shape kv can hand back — a JSON string, an already-deserialized + * array, null/undefined for "never saved" — and any corruption inside it + * (non-string entries, an absurdly long list). Corrupt input degrades to an + * empty set rather than throwing: the worst outcome is a recommended tile + * reappearing, never a broken Apps tab. + * + * @param {unknown} raw - value returned by `puter.kv.get` + * @returns {Set} + */ +export function parseRemovedApps (raw) { + let list = raw; + if ( typeof raw === 'string' ) { + try { + list = JSON.parse(raw); + } catch ( _e ) { + return new Set(); + } + } + if ( ! Array.isArray(list) ) return new Set(); + const names = list.filter(name => typeof name === 'string' && name.length > 0); + // Keep the tail: new names append there, so the cap sheds oldest first. + return new Set(names.slice(-REMOVED_APPS_MAX)); +} + +/** + * Serialize a set of removed app names to the persisted array shape, applying + * the same cap as {@link parseRemovedApps} so the read and write shapes stay + * in lockstep. + * + * @param {Set|Iterable} names + * @returns {string[]} + */ +export function serializeRemovedApps (names) { + const out = []; + const seen = new Set(); + // Reject strings even though they're iterable — a raw kv value passed by + // mistake must not shred into single-character "names". + if ( names && typeof names !== 'string' && typeof names[Symbol.iterator] === 'function' ) { + for ( const name of names ) { + if ( typeof name !== 'string' || name.length === 0 ) continue; + if ( seen.has(name) ) continue; + seen.add(name); + out.push(name); + } + } + return out.slice(-REMOVED_APPS_MAX); +} diff --git a/src/gui/src/UI/Dashboard/removedApps.test.js b/src/gui/src/UI/Dashboard/removedApps.test.js new file mode 100644 index 000000000..7313004fb --- /dev/null +++ b/src/gui/src/UI/Dashboard/removedApps.test.js @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, it, expect } from 'vitest'; +import { parseRemovedApps, serializeRemovedApps, REMOVED_APPS_MAX } from './removedApps.js'; + +describe('parseRemovedApps', () => { + it('returns an empty set when nothing was saved', () => { + expect(parseRemovedApps(null)).toEqual(new Set()); + expect(parseRemovedApps(undefined)).toEqual(new Set()); + }); + + it('parses a JSON string of names', () => { + expect(parseRemovedApps('["chess","camera"]')).toEqual(new Set(['chess', 'camera'])); + }); + + it('accepts an already-deserialized array', () => { + // Some kv backends hand back the parsed value rather than the string. + expect(parseRemovedApps(['chess'])).toEqual(new Set(['chess'])); + }); + + it('degrades corrupt JSON to an empty set instead of throwing', () => { + expect(parseRemovedApps('{not json')).toEqual(new Set()); + }); + + it('degrades non-array values to an empty set', () => { + expect(parseRemovedApps('{"a":1}')).toEqual(new Set()); + expect(parseRemovedApps(42)).toEqual(new Set()); + expect(parseRemovedApps({})).toEqual(new Set()); + }); + + it('drops non-string and empty entries', () => { + expect(parseRemovedApps(['chess', '', null, 7, {}, 'camera'])) + .toEqual(new Set(['chess', 'camera'])); + }); + + it('keeps only the newest names past the cap', () => { + const list = Array.from({ length: REMOVED_APPS_MAX + 10 }, (_, i) => `app-${i}`); + const parsed = parseRemovedApps(list); + expect(parsed.size).toBe(REMOVED_APPS_MAX); + // Oldest entries (the head) fall off; the newest survive. + expect(parsed.has('app-0')).toBe(false); + expect(parsed.has(`app-${REMOVED_APPS_MAX + 9}`)).toBe(true); + }); +}); + +describe('serializeRemovedApps', () => { + it('serializes a set to an array of names', () => { + expect(serializeRemovedApps(new Set(['chess', 'camera']))).toEqual(['chess', 'camera']); + }); + + it('drops unusable entries and duplicates', () => { + expect(serializeRemovedApps(['chess', '', null, 'chess', 'camera'])) + .toEqual(['chess', 'camera']); + }); + + it('handles non-iterable input defensively', () => { + expect(serializeRemovedApps(null)).toEqual([]); + expect(serializeRemovedApps(undefined)).toEqual([]); + expect(serializeRemovedApps(42)).toEqual([]); + }); + + it('rejects a raw string instead of shredding it into characters', () => { + expect(serializeRemovedApps('chess')).toEqual([]); + }); + + it('applies the cap, shedding oldest first', () => { + const names = Array.from({ length: REMOVED_APPS_MAX + 5 }, (_, i) => `app-${i}`); + const out = serializeRemovedApps(names); + expect(out.length).toBe(REMOVED_APPS_MAX); + expect(out[0]).toBe('app-5'); + expect(out[out.length - 1]).toBe(`app-${REMOVED_APPS_MAX + 4}`); + }); + + it('round-trips with parseRemovedApps', () => { + const set = new Set(['chess', 'camera', 'vault']); + expect(parseRemovedApps(JSON.stringify(serializeRemovedApps(set)))).toEqual(set); + }); +});