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);
+ });
+});