fix: keep uninstalled recommended apps from resurrecting in the Apps tab

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.
This commit is contained in:
jelveh
2026-08-05 11:01:14 -07:00
parent 6df292f495
commit 036008d8a7
3 changed files with 249 additions and 11 deletions
+68 -11
View File
@@ -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();
+86
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
/**
* 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<string>}
*/
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<string>|Iterable<string>} 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);
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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);
});
});