mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-23 22:47:19 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user